From 520893693b53ede4451396e8adaeed08d865c97e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:50:34 +0800 Subject: [PATCH 1/6] fix: reconcile LongBridge target schedulers Co-Authored-By: Codex --- .github/workflows/sync-cloud-run-env.yml | 189 +++++++++----------- scripts/build_cloud_run_env_sync_plan.py | 7 + scripts/reconcile_cloud_runtime.py | 2 +- tests/test_build_cloud_run_env_sync_plan.py | 47 +++++ tests/test_reconcile_cloud_runtime.py | 5 - tests/test_sync_cloud_run_env_workflow.sh | 20 ++- 6 files changed, 153 insertions(+), 117 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index b81df1e..1e6fd8e 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -117,7 +117,6 @@ jobs: CLOUD_SCHEDULER_MAIN_TIME: ${{ vars.CLOUD_SCHEDULER_MAIN_TIME }} CLOUD_SCHEDULER_PROBE_TIME: ${{ vars.CLOUD_SCHEDULER_PROBE_TIME }} CLOUD_SCHEDULER_PRECHECK_TIME: ${{ vars.CLOUD_SCHEDULER_PRECHECK_TIME }} - MONITOR_DISPATCHER_OWNER_LABEL: ${{ vars.MONITOR_DISPATCHER_OWNER_LABEL || 'SG' }} ACCOUNT_PREFIX: ${{ vars.ACCOUNT_PREFIX }} TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} LONGPORT_APP_KEY_SECRET_NAME: ${{ vars.LONGPORT_APP_KEY_SECRET_NAME }} @@ -862,76 +861,7 @@ jobs: remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS") fi - monitor_targets_json="$( - python - <<'PY' - import json - import os - import subprocess - - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - project = os.environ.get("GCP_PROJECT_ID", "").strip() - default_region = os.environ.get("CLOUD_RUN_REGION", "").strip() - configured_targets = json.loads( - os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "{}" - ).get("targets") or [] - region_by_service: dict[str, str] = {} - service_url_by_service: dict[str, str] = {} - for item in configured_targets: - if not isinstance(item, dict): - continue - service_name = str(item.get("service_name") or item.get("service") or "").strip() - if not service_name: - continue - region = str(item.get("region") or "").strip() - if region: - region_by_service[service_name] = region - service_url = str(item.get("service_url") or "").strip() - if service_url: - service_url_by_service[service_name] = service_url - - output = [] - for target in plan.get("targets") or []: - if not isinstance(target, dict): - continue - env = target.get("env") or {} - runtime_target = json.loads(env.get("RUNTIME_TARGET_JSON") or "{}") - service_name = str(target.get("service_name") or "").strip() - if not service_name: - continue - service_url = service_url_by_service.get(service_name) - if not service_url: - region = region_by_service.get(service_name) or default_region - if not region: - continue - service_url = subprocess.check_output( - [ - "gcloud", - "run", - "services", - "describe", - service_name, - f"--project={project}", - f"--region={region}", - "--format=value(status.url)", - ], - text=True, - ).strip() - if not service_url: - continue - output.append( - { - "service_name": service_name, - "service_url": service_url, - "strategy_profile": target.get("strategy_profile") or env.get("STRATEGY_PROFILE"), - "account_scope": runtime_target.get("account_scope"), - "runtime_target_enabled": env.get("RUNTIME_TARGET_ENABLED", "true"), - "scheduler": target.get("scheduler") or {}, - } - ) - print(json.dumps({"targets": output}, separators=(",", ":"))) - PY - )" - env_pairs+=("MONITOR_DISPATCH_TARGETS_JSON=${monitor_targets_json}") + remove_env_vars+=("MONITOR_DISPATCH_TARGETS_JSON" "LONGBRIDGE_MONITOR_DISPATCH_TARGETS_JSON") gcloud_args=( run services update "${CLOUD_RUN_SERVICE}" @@ -992,18 +922,21 @@ jobs: targets[0], ) scheduler = target.get("scheduler") or {} + target_env = target.get("env") or {} print(str(scheduler.get("timezone") or os.environ.get("LONGBRIDGE_MARKET_TIMEZONE", "")).strip() or "America/New_York") print(str(scheduler.get("main_time") or os.environ.get("CLOUD_SCHEDULER_MAIN_TIME", "").strip() or "45 15")) print(str(scheduler.get("probe_time") or os.environ.get("CLOUD_SCHEDULER_PROBE_TIME", "").strip() or "35 9,15")) print(str(scheduler.get("precheck_time") or os.environ.get("CLOUD_SCHEDULER_PRECHECK_TIME", "").strip() or "45 9")) + print(str(target_env.get("RUNTIME_TARGET_ENABLED") or "true").strip().lower()) PY ) market_timezone="${scheduler_config[0]}" main_time="${scheduler_config[1]}" probe_time="${scheduler_config[2]}" precheck_time="${scheduler_config[3]}" - # Keep probe/precheck parsed for runtime-target schema parity; this step only syncs the main scheduler. - : "${probe_time}" "${precheck_time}" + runtime_target_enabled="${scheduler_config[4]}" + # Keep probe parsed for runtime-target schema parity. + : "${probe_time}" service_url="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" \ --project="${GCP_PROJECT_ID}" \ @@ -1083,6 +1016,24 @@ jobs: )" fi + desired_precheck_schedule="$(CURRENT_SCHEDULE="${desired_schedule}" SCHEDULE_TIME="${precheck_time}" python - <<'PY' + import os + + current_fields = os.environ["CURRENT_SCHEDULE"].split() + time_fields = os.environ["SCHEDULE_TIME"].split() + if len(current_fields) != 5: + raise SystemExit(f"Cloud Scheduler schedule must have 5 fields: {os.environ['CURRENT_SCHEDULE']!r}") + if len(time_fields) == 5: + print(" ".join(time_fields)) + elif len(time_fields) == 2: + print(" ".join([*time_fields, *current_fields[2:]])) + else: + raise SystemExit( + f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}" + ) + PY + )" + scheduler_uri="${service_url}/run" if [ -n "${current_schedule}" ]; then echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule}, timezone to ${market_timezone}, and URI to ${scheduler_uri}." @@ -1108,42 +1059,70 @@ jobs: --quiet fi - if [ "${DEPLOYMENT_LABEL:-}" = "${MONITOR_DISPATCHER_OWNER_LABEL:-SG}" ]; then - monitor_job_name="longbridge-monitor-dispatcher-scheduler" - monitor_uri="${service_url}/monitor-dispatch" - if gcloud scheduler jobs describe "${monitor_job_name}" \ + precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler" + precheck_uri="${service_url}/dry-run" + if gcloud scheduler jobs describe "${precheck_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" >/dev/null 2>&1; then + echo "Updating Cloud Scheduler precheck ${precheck_job_name} to ${desired_precheck_schedule}." + gcloud scheduler jobs update http "${precheck_job_name}" \ --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" >/dev/null 2>&1; then - echo "Updating Cloud Scheduler job ${monitor_job_name} to ${monitor_uri}." - gcloud scheduler jobs update http "${monitor_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --uri="${monitor_uri}" \ - --http-method=POST \ - --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ - --oidc-token-audience="${service_url}" \ - --schedule="*/5 * * * *" \ - --time-zone="UTC" \ - --attempt-deadline=180s \ - --quiet - else - echo "Creating Cloud Scheduler job ${monitor_job_name} at ${monitor_uri}." - gcloud scheduler jobs create http "${monitor_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --uri="${monitor_uri}" \ - --http-method=POST \ - --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ - --oidc-token-audience="${service_url}" \ - --schedule="*/5 * * * *" \ - --time-zone="UTC" \ - --attempt-deadline=180s \ - --quiet - fi + --location="${scheduler_location}" \ + --uri="${precheck_uri}" \ + --schedule="${desired_precheck_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=POST \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + --attempt-deadline=180s \ + --max-retry-attempts=0 \ + --max-retry-duration=0s \ + --quiet else - echo "Skipping shared LongBridge monitor dispatcher scheduler from ${DEPLOYMENT_LABEL}; owner is ${MONITOR_DISPATCHER_OWNER_LABEL:-SG}." + echo "Creating Cloud Scheduler precheck ${precheck_job_name} at ${desired_precheck_schedule}." + gcloud scheduler jobs create http "${precheck_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --uri="${precheck_uri}" \ + --schedule="${desired_precheck_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=POST \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + --attempt-deadline=180s \ + --max-retry-attempts=0 \ + --max-retry-duration=0s \ + --quiet fi + managed_scheduler_jobs=("${job_name}" "${precheck_job_name}") + for managed_job_name in "${managed_scheduler_jobs[@]}"; do + managed_job_state="$(gcloud scheduler jobs describe "${managed_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --format='value(state)')" + case "${runtime_target_enabled}" in + 1|true|yes|on) + if [ "${managed_job_state}" = "PAUSED" ]; then + echo "Resuming Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is enabled." + gcloud scheduler jobs resume "${managed_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --quiet + fi + ;; + *) + if [ "${managed_job_state}" != "PAUSED" ]; then + echo "Pausing Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is disabled." + gcloud scheduler jobs pause "${managed_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --quiet + fi + ;; + esac + done + - name: Reconcile legacy Cloud Scheduler jobs if: steps.config.outputs.env_sync_enabled == 'true' env: diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 7d3a3d7..0ac88aa 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -399,6 +399,10 @@ def _build_target_plan( per_service_mode=per_service_mode, allow_shared_fallback=name in SHARED_TARGET_FALLBACK_ENV, ) + if name == "RUNTIME_TARGET_ENABLED" and service_name == str( + env.get("CLOUD_RUN_SERVICE") or "" + ).strip(): + value = _coerce_env_value(env.get(name)) or value # 2. runtime_target-derived if value is None and name == "LONGBRIDGE_DRY_RUN_ONLY": dry_run_value = runtime_target.get("dry_run_only") @@ -413,6 +417,9 @@ def _build_target_plan( if override_value is not None: value = _coerce_env_value(override_value) + if name == "RUNTIME_TARGET_ENABLED" and value is None: + value = "true" + if value is None: remove_env_vars.append(name) else: diff --git a/scripts/reconcile_cloud_runtime.py b/scripts/reconcile_cloud_runtime.py index d223aa4..e8e3e16 100755 --- a/scripts/reconcile_cloud_runtime.py +++ b/scripts/reconcile_cloud_runtime.py @@ -277,7 +277,7 @@ def ensure_latest_traffic( def _legacy_jobs_for_target(platform: str, target: RuntimeTarget) -> list[str]: service = target.service_name - jobs = [f"{service}-probe-scheduler", f"{service}-precheck-scheduler"] + jobs = [f"{service}-probe-scheduler"] if service.endswith("-service"): base = service[: -len("-service")] jobs.extend([f"{base}-probe-scheduler", f"{base}-precheck-scheduler"]) diff --git a/tests/test_build_cloud_run_env_sync_plan.py b/tests/test_build_cloud_run_env_sync_plan.py index dac461e..d38ced2 100644 --- a/tests/test_build_cloud_run_env_sync_plan.py +++ b/tests/test_build_cloud_run_env_sync_plan.py @@ -219,3 +219,50 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): live_mega["env"]["LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH"] == "gs://runtime/mega/snapshot.csv.manifest.json" ) + + +def test_current_service_uses_environment_runtime_enabled_state(): + payload = { + "defaults": { + "GLOBAL_TELEGRAM_CHAT_ID": "5992562050", + "NOTIFY_LANG": "zh", + "LONGBRIDGE_MARKET": "US", + }, + "targets": [ + { + "service": service, + "account_prefix": scope, + "runtime_target": json.loads( + runtime_target_json( + "tqqq_growth_income", + deployment_selector=scope, + account_scope=scope, + service_name=service, + ) + ), + } + for service, scope in ( + ("longbridge-quant-paper-service", "PAPER"), + ("longbridge-quant-sg-service", "SG"), + ) + ], + } + env = { + **os.environ, + "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload), + "CLOUD_RUN_SERVICE": "longbridge-quant-sg-service", + "RUNTIME_TARGET_ENABLED": "false", + } + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + check=True, + capture_output=True, + text=True, + env=env, + ) + + plan = json.loads(result.stdout) + by_service = {target["service_name"]: target for target in plan["targets"]} + assert by_service["longbridge-quant-sg-service"]["env"]["RUNTIME_TARGET_ENABLED"] == "false" + assert by_service["longbridge-quant-paper-service"]["env"]["RUNTIME_TARGET_ENABLED"] == "true" diff --git a/tests/test_reconcile_cloud_runtime.py b/tests/test_reconcile_cloud_runtime.py index a5c2a34..53fa366 100644 --- a/tests/test_reconcile_cloud_runtime.py +++ b/tests/test_reconcile_cloud_runtime.py @@ -164,11 +164,6 @@ def fake_run(args, *, json_output=False, dry_run=False): ("longbridge-quant-paper-service-probe-scheduler", "europe-west1"), ("longbridge-quant-paper-service-probe-scheduler", "asia-northeast1"), ("longbridge-quant-paper-service-probe-scheduler", "asia-south1"), - ("longbridge-quant-paper-service-precheck-scheduler", "asia-east2"), - ("longbridge-quant-paper-service-precheck-scheduler", "asia-east1"), - ("longbridge-quant-paper-service-precheck-scheduler", "europe-west1"), - ("longbridge-quant-paper-service-precheck-scheduler", "asia-northeast1"), - ("longbridge-quant-paper-service-precheck-scheduler", "asia-south1"), ("longbridge-quant-paper-probe-scheduler", "asia-east2"), ("longbridge-quant-paper-probe-scheduler", "asia-east1"), ("longbridge-quant-paper-probe-scheduler", "europe-west1"), diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index ada5329..22ad9be 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -61,7 +61,7 @@ grep -Fq 'STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.STRATEGY_PLUG grep -Fq 'STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }}' "$workflow_file" grep -Fq 'STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }}' "$workflow_file" grep -Fq 'STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN }}' "$workflow_file" -grep -Fq 'STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN }}' "$workflow_file" +grep -Fq 'STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.TG_TOKEN }}' "$workflow_file" grep -Fq 'TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}' "$workflow_file" grep -Fq 'LONGPORT_APP_KEY_SECRET_NAME: ${{ vars.LONGPORT_APP_KEY_SECRET_NAME }}' "$workflow_file" grep -Fq 'LONGPORT_APP_SECRET_SECRET_NAME: ${{ vars.LONGPORT_APP_SECRET_SECRET_NAME }}' "$workflow_file" @@ -209,11 +209,11 @@ grep -Fq 'join_by_delimiter()' "$workflow_file" grep -Fq 'gcloud_args+=(--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")")' "$workflow_file" grep -Fq 'gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")")' "$workflow_file" grep -Fq -- '--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")"' "$workflow_file" -grep -Fq 'MONITOR_DISPATCH_TARGETS_JSON=${monitor_targets_json}' "$workflow_file" -grep -Fq 'region_by_service' "$workflow_file" -grep -Fq 'service_url_by_service' "$workflow_file" +grep -Fq 'remove_env_vars+=("MONITOR_DISPATCH_TARGETS_JSON" "LONGBRIDGE_MONITOR_DISPATCH_TARGETS_JSON")' "$workflow_file" grep -Fq 'Sync Cloud Scheduler schedule' "$workflow_file" grep -Fq 'scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}"' "$workflow_file" +grep -Fq 'target_env = target.get("env") or {}' "$workflow_file" +grep -Fq 'runtime_target_enabled="${scheduler_config[4]}"' "$workflow_file" grep -Fq 'scheduler_job_candidates=("${CLOUD_RUN_SERVICE}-scheduler")' "$workflow_file" grep -Fq 'current_schedule="$(gcloud scheduler jobs describe "${candidate_job}"' "$workflow_file" grep -Fq 'desired_schedule="$(CURRENT_SCHEDULE="${current_schedule}" SCHEDULE_TIME="${main_time}" python - <<' "$workflow_file" @@ -222,8 +222,11 @@ grep -Fq 'print(" ".join(time_fields))' "$workflow_file" grep -Fq 'print(" ".join([*time_fields, *current_fields[2:]]))' "$workflow_file" grep -Fq 'gcloud scheduler jobs update http "${job_name}"' "$workflow_file" grep -Fq 'gcloud scheduler jobs create http "${job_name}"' "$workflow_file" -grep -Fq 'monitor_job_name="longbridge-monitor-dispatcher-scheduler"' "$workflow_file" -grep -Fq 'monitor_uri="${service_url}/monitor-dispatch"' "$workflow_file" +grep -Fq 'precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler"' "$workflow_file" +grep -Fq 'precheck_uri="${service_url}/dry-run"' "$workflow_file" +grep -Fq 'managed_scheduler_jobs=("${job_name}" "${precheck_job_name}")' "$workflow_file" +grep -Fq 'gcloud scheduler jobs resume "${managed_job_name}"' "$workflow_file" +grep -Fq 'gcloud scheduler jobs pause "${managed_job_name}"' "$workflow_file" grep -Fq 'Reconcile Cloud Run traffic' "$workflow_file" grep -Fq 'python3 scripts/reconcile_cloud_runtime.py --platform longbridge --ensure-latest-traffic' "$workflow_file" grep -Fq 'Reconcile legacy Cloud Scheduler jobs' "$workflow_file" @@ -231,6 +234,11 @@ grep -Fq 'python3 scripts/reconcile_cloud_runtime.py --platform longbridge --del grep -Fq -- '--schedule="${desired_schedule}"' "$workflow_file" grep -Fq -- '--time-zone="${market_timezone}"' "$workflow_file" +if grep -Fq 'longbridge-monitor-dispatcher-scheduler' "$workflow_file"; then + echo "unexpected shared monitor dispatcher scheduler still present" >&2 + exit 1 +fi + if grep -Fq 'legacy_jobs=(' "$workflow_file"; then echo "unexpected inline legacy scheduler deletion logic still present" >&2 exit 1 From 44980d5ece2d6c676838a4b8e8ec6eb0e24bb8a9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:00:23 +0800 Subject: [PATCH 2/6] fix: complete LongBridge scheduler migration Co-Authored-By: Codex --- .github/workflows/sync-cloud-run-env.yml | 69 ++++++++++++++++++++++- scripts/reconcile_cloud_runtime.py | 2 +- tests/test_reconcile_cloud_runtime.py | 5 -- tests/test_scheduler_workflow.py | 12 ++++ tests/test_sync_cloud_run_env_workflow.sh | 10 +++- 5 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 tests/test_scheduler_workflow.py diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 1e6fd8e..c310361 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -1034,6 +1034,24 @@ jobs: PY )" + desired_probe_schedule="$(CURRENT_SCHEDULE="${desired_schedule}" SCHEDULE_TIME="${probe_time}" python - <<'PY' + import os + + current_fields = os.environ["CURRENT_SCHEDULE"].split() + time_fields = os.environ["SCHEDULE_TIME"].split() + if len(current_fields) != 5: + raise SystemExit(f"Cloud Scheduler schedule must have 5 fields: {os.environ['CURRENT_SCHEDULE']!r}") + if len(time_fields) == 5: + print(" ".join(time_fields)) + elif len(time_fields) == 2: + print(" ".join([*time_fields, *current_fields[2:]])) + else: + raise SystemExit( + f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}" + ) + PY + )" + scheduler_uri="${service_url}/run" if [ -n "${current_schedule}" ]; then echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule}, timezone to ${market_timezone}, and URI to ${scheduler_uri}." @@ -1059,6 +1077,42 @@ jobs: --quiet fi + probe_job_name="${CLOUD_RUN_SERVICE}-probe-scheduler" + probe_uri="${service_url}/probe" + if gcloud scheduler jobs describe "${probe_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" >/dev/null 2>&1; then + echo "Updating Cloud Scheduler probe ${probe_job_name} to ${desired_probe_schedule}." + gcloud scheduler jobs update http "${probe_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --uri="${probe_uri}" \ + --schedule="${desired_probe_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=POST \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + --attempt-deadline=180s \ + --max-retry-attempts=0 \ + --max-retry-duration=0s \ + --quiet + else + echo "Creating Cloud Scheduler probe ${probe_job_name} at ${desired_probe_schedule}." + gcloud scheduler jobs create http "${probe_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --uri="${probe_uri}" \ + --schedule="${desired_probe_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=POST \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + --attempt-deadline=180s \ + --max-retry-attempts=0 \ + --max-retry-duration=0s \ + --quiet + fi + precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler" precheck_uri="${service_url}/dry-run" if gcloud scheduler jobs describe "${precheck_job_name}" \ @@ -1095,7 +1149,7 @@ jobs: --quiet fi - managed_scheduler_jobs=("${job_name}" "${precheck_job_name}") + managed_scheduler_jobs=("${job_name}" "${probe_job_name}" "${precheck_job_name}") for managed_job_name in "${managed_scheduler_jobs[@]}"; do managed_job_state="$(gcloud scheduler jobs describe "${managed_job_name}" \ --project="${GCP_PROJECT_ID}" \ @@ -1123,6 +1177,19 @@ jobs: esac done + if [ "${DEPLOYMENT_LABEL:-}" = "SG" ]; then + monitor_job_name="longbridge-monitor-dispatcher-scheduler" + if gcloud scheduler jobs describe "${monitor_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" >/dev/null 2>&1; then + echo "Deleting obsolete shared monitor dispatcher ${monitor_job_name}." + gcloud scheduler jobs delete "${monitor_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --quiet + fi + fi + - name: Reconcile legacy Cloud Scheduler jobs if: steps.config.outputs.env_sync_enabled == 'true' env: diff --git a/scripts/reconcile_cloud_runtime.py b/scripts/reconcile_cloud_runtime.py index e8e3e16..3af0447 100755 --- a/scripts/reconcile_cloud_runtime.py +++ b/scripts/reconcile_cloud_runtime.py @@ -277,7 +277,7 @@ def ensure_latest_traffic( def _legacy_jobs_for_target(platform: str, target: RuntimeTarget) -> list[str]: service = target.service_name - jobs = [f"{service}-probe-scheduler"] + jobs: list[str] = [] if service.endswith("-service"): base = service[: -len("-service")] jobs.extend([f"{base}-probe-scheduler", f"{base}-precheck-scheduler"]) diff --git a/tests/test_reconcile_cloud_runtime.py b/tests/test_reconcile_cloud_runtime.py index 53fa366..a2bd1bc 100644 --- a/tests/test_reconcile_cloud_runtime.py +++ b/tests/test_reconcile_cloud_runtime.py @@ -159,11 +159,6 @@ def fake_run(args, *, json_output=False, dry_run=False): self.assertEqual( delete_commands, [ - ("longbridge-quant-paper-service-probe-scheduler", "asia-east2"), - ("longbridge-quant-paper-service-probe-scheduler", "asia-east1"), - ("longbridge-quant-paper-service-probe-scheduler", "europe-west1"), - ("longbridge-quant-paper-service-probe-scheduler", "asia-northeast1"), - ("longbridge-quant-paper-service-probe-scheduler", "asia-south1"), ("longbridge-quant-paper-probe-scheduler", "asia-east2"), ("longbridge-quant-paper-probe-scheduler", "asia-east1"), ("longbridge-quant-paper-probe-scheduler", "europe-west1"), diff --git a/tests/test_scheduler_workflow.py b/tests/test_scheduler_workflow.py new file mode 100644 index 0000000..afeae5a --- /dev/null +++ b/tests/test_scheduler_workflow.py @@ -0,0 +1,12 @@ +from pathlib import Path + + +def test_replacement_probe_and_precheck_exist_before_shared_dispatcher_cleanup() -> None: + workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + + probe = workflow.index('probe_job_name="${CLOUD_RUN_SERVICE}-probe-scheduler"') + precheck = workflow.index('precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler"') + cleanup = workflow.index('gcloud scheduler jobs delete "${monitor_job_name}"') + + assert probe < cleanup + assert precheck < cleanup diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 22ad9be..ab660ed 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -222,11 +222,15 @@ grep -Fq 'print(" ".join(time_fields))' "$workflow_file" grep -Fq 'print(" ".join([*time_fields, *current_fields[2:]]))' "$workflow_file" grep -Fq 'gcloud scheduler jobs update http "${job_name}"' "$workflow_file" grep -Fq 'gcloud scheduler jobs create http "${job_name}"' "$workflow_file" +grep -Fq 'probe_job_name="${CLOUD_RUN_SERVICE}-probe-scheduler"' "$workflow_file" +grep -Fq 'probe_uri="${service_url}/probe"' "$workflow_file" grep -Fq 'precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler"' "$workflow_file" grep -Fq 'precheck_uri="${service_url}/dry-run"' "$workflow_file" -grep -Fq 'managed_scheduler_jobs=("${job_name}" "${precheck_job_name}")' "$workflow_file" +grep -Fq 'managed_scheduler_jobs=("${job_name}" "${probe_job_name}" "${precheck_job_name}")' "$workflow_file" grep -Fq 'gcloud scheduler jobs resume "${managed_job_name}"' "$workflow_file" grep -Fq 'gcloud scheduler jobs pause "${managed_job_name}"' "$workflow_file" +grep -Fq 'monitor_job_name="longbridge-monitor-dispatcher-scheduler"' "$workflow_file" +grep -Fq 'gcloud scheduler jobs delete "${monitor_job_name}"' "$workflow_file" grep -Fq 'Reconcile Cloud Run traffic' "$workflow_file" grep -Fq 'python3 scripts/reconcile_cloud_runtime.py --platform longbridge --ensure-latest-traffic' "$workflow_file" grep -Fq 'Reconcile legacy Cloud Scheduler jobs' "$workflow_file" @@ -234,8 +238,8 @@ grep -Fq 'python3 scripts/reconcile_cloud_runtime.py --platform longbridge --del grep -Fq -- '--schedule="${desired_schedule}"' "$workflow_file" grep -Fq -- '--time-zone="${market_timezone}"' "$workflow_file" -if grep -Fq 'longbridge-monitor-dispatcher-scheduler' "$workflow_file"; then - echo "unexpected shared monitor dispatcher scheduler still present" >&2 +if grep -Fq 'monitor_uri="${service_url}/monitor-dispatch"' "$workflow_file"; then + echo "unexpected shared monitor dispatcher creation still present" >&2 exit 1 fi From 5885c9b5e337fc672b68bbd604754a33804553da Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:06:41 +0800 Subject: [PATCH 3/6] fix: gate shared scheduler cleanup Co-Authored-By: Codex --- .github/workflows/sync-cloud-run-env.yml | 119 ++++++++++++++++++++--- tests/test_scheduler_workflow.py | 11 ++- 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index c310361..8368b63 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -1177,19 +1177,6 @@ jobs: esac done - if [ "${DEPLOYMENT_LABEL:-}" = "SG" ]; then - monitor_job_name="longbridge-monitor-dispatcher-scheduler" - if gcloud scheduler jobs describe "${monitor_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" >/dev/null 2>&1; then - echo "Deleting obsolete shared monitor dispatcher ${monitor_job_name}." - gcloud scheduler jobs delete "${monitor_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --quiet - fi - fi - - name: Reconcile legacy Cloud Scheduler jobs if: steps.config.outputs.env_sync_enabled == 'true' env: @@ -1270,3 +1257,109 @@ jobs: --async \ --quiet done <<< "${old_digests}" + + cleanup-shared-monitor: + name: Retire shared monitor dispatcher + needs: sync + if: > + needs.sync.result == 'success' && + ( + github.event_name == 'workflow_run' || + (github.event_name == 'workflow_dispatch' && inputs.target == 'configured') + ) + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: longbridge-sg + permissions: + contents: read + id-token: write + env: + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }} + CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} + steps: + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ env.GCP_PROJECT_ID }} + version: ">= 416.0.0" + + - name: Verify per-service scheduler replacements + id: replacements + run: | + set -euo pipefail + + replacements_ready="true" + mapfile -t configured_targets < <(python3 - <<'PY' + import json + import os + + payload = json.loads(os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "{}") + for target in payload.get("targets") or []: + if not isinstance(target, dict): + continue + service_name = str(target.get("service_name") or target.get("service") or "").strip() + scheduler = target.get("scheduler") if isinstance(target.get("scheduler"), dict) else {} + location = str( + target.get("scheduler_location") + or scheduler.get("location") + or target.get("region") + or target.get("cloud_run_region") + or "" + ).strip() + if service_name: + print(f"{service_name}\t{location}") + PY + ) + + if [ "${#configured_targets[@]}" -eq 0 ]; then + echo "No configured LongBridge targets; keeping the shared monitor dispatcher." >&2 + replacements_ready="false" + fi + + for configured_target in "${configured_targets[@]}"; do + IFS=$'\t' read -r service_name scheduler_location <<< "${configured_target}" + if [ -z "${scheduler_location}" ]; then + echo "No scheduler location for ${service_name}; keeping the shared monitor dispatcher." >&2 + replacements_ready="false" + continue + fi + replacement_jobs=("${service_name}-probe-scheduler" "${service_name}-precheck-scheduler") + for replacement_job in "${replacement_jobs[@]}"; do + if ! gcloud scheduler jobs describe "${replacement_job}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" >/dev/null 2>&1; then + echo "Replacement ${replacement_job} is not ready in ${scheduler_location}." >&2 + replacements_ready="false" + fi + done + done + + echo "ready=${replacements_ready}" >> "$GITHUB_OUTPUT" + + - name: Delete obsolete shared monitor dispatcher + if: steps.replacements.outputs.ready == 'true' + run: | + set -euo pipefail + + scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" + if [ -z "${scheduler_location}" ]; then + echo "Shared monitor dispatcher cleanup requires a scheduler location." >&2 + exit 1 + fi + monitor_job_name="longbridge-monitor-dispatcher-scheduler" + if gcloud scheduler jobs describe "${monitor_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" >/dev/null 2>&1; then + echo "Deleting obsolete shared monitor dispatcher ${monitor_job_name}." + gcloud scheduler jobs delete "${monitor_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --quiet + fi diff --git a/tests/test_scheduler_workflow.py b/tests/test_scheduler_workflow.py index afeae5a..12a5259 100644 --- a/tests/test_scheduler_workflow.py +++ b/tests/test_scheduler_workflow.py @@ -6,7 +6,14 @@ def test_replacement_probe_and_precheck_exist_before_shared_dispatcher_cleanup() probe = workflow.index('probe_job_name="${CLOUD_RUN_SERVICE}-probe-scheduler"') precheck = workflow.index('precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler"') + cleanup_job = workflow.index("cleanup-shared-monitor:") cleanup = workflow.index('gcloud scheduler jobs delete "${monitor_job_name}"') - assert probe < cleanup - assert precheck < cleanup + assert probe < cleanup_job < cleanup + assert precheck < cleanup_job < cleanup + + cleanup_section = workflow[cleanup_job:] + assert "needs: sync" in cleanup_section + assert "environment: longbridge-sg" in cleanup_section + assert 'replacement_jobs=("${service_name}-probe-scheduler" "${service_name}-precheck-scheduler")' in cleanup_section + assert "if: steps.replacements.outputs.ready == 'true'" in cleanup_section From 7bbe46d9405a2452efde887c6bd271a25da00f02 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:19:54 +0800 Subject: [PATCH 4/6] fix: reject ambiguous scheduler cron Co-Authored-By: Codex --- .github/workflows/sync-cloud-run-env.yml | 4 +++ scripts/build_cloud_run_env_sync_plan.py | 15 ++++++++++- tests/test_build_cloud_run_env_sync_plan.py | 28 +++++++++++++++++++++ tests/test_scheduler_workflow.py | 7 ++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 8368b63..7a8df61 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -992,6 +992,10 @@ jobs: if len(time_fields) == 5: print(" ".join(time_fields)) elif len(time_fields) == 2: + if current_fields[2] != "*" and current_fields[4] != "*": + raise SystemExit( + "Cloud Scheduler schedule cannot constrain both day-of-month and day-of-week" + ) print(" ".join([*time_fields, *current_fields[2:]])) else: raise SystemExit( diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 0ac88aa..4008029 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -480,10 +480,23 @@ def _build_scheduler_plan( per_service_mode=per_service_mode, allow_shared_fallback=True, ) - scheduler[key] = str(runtime_scheduler.get(key) or configured_value or SCHEDULER_TIME_DEFAULTS[key]) + scheduler[key] = _validate_scheduler_time( + key, + str(runtime_scheduler.get(key) or configured_value or SCHEDULER_TIME_DEFAULTS[key]), + ) return scheduler +def _validate_scheduler_time(name: str, value: str) -> str: + fields = value.split() + if len(fields) == 5 and fields[2] != "*" and fields[4] != "*": + raise ValueError( + f"{name} cannot constrain both day-of-month and day-of-week; " + "Cloud Scheduler applies OR semantics to those fields" + ) + return value + + def _validate_profile_inputs( *, service_name: str, diff --git a/tests/test_build_cloud_run_env_sync_plan.py b/tests/test_build_cloud_run_env_sync_plan.py index d38ced2..2237a0b 100644 --- a/tests/test_build_cloud_run_env_sync_plan.py +++ b/tests/test_build_cloud_run_env_sync_plan.py @@ -82,6 +82,34 @@ def test_build_cloud_run_env_sync_plan_legacy_mode_uses_shared_env(): } +def test_build_cloud_run_env_sync_plan_rejects_ambiguous_cloud_scheduler_cron(): + env = { + **os.environ, + "CLOUD_RUN_SERVICE": "longbridge-quant-paper-service", + "GLOBAL_TELEGRAM_CHAT_ID": "5992562050", + "NOTIFY_LANG": "zh", + "ACCOUNT_PREFIX": "PAPER", + "RUNTIME_TARGET_JSON": runtime_target_json( + "soxl_soxx_trend_income", + deployment_selector="PAPER", + account_scope="PAPER", + service_name="longbridge-quant-paper-service", + ), + "LONGBRIDGE_MARKET": "US", + "CLOUD_SCHEDULER_PROBE_TIME": "35 9 1-7 * 1-5", + } + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert "cannot constrain both day-of-month and day-of-week" in result.stderr + + def test_build_cloud_run_env_sync_plan_requires_target_snapshot_in_per_service_mode(): payload = { "defaults": { diff --git a/tests/test_scheduler_workflow.py b/tests/test_scheduler_workflow.py index 12a5259..131435a 100644 --- a/tests/test_scheduler_workflow.py +++ b/tests/test_scheduler_workflow.py @@ -17,3 +17,10 @@ def test_replacement_probe_and_precheck_exist_before_shared_dispatcher_cleanup() assert "environment: longbridge-sg" in cleanup_section assert 'replacement_jobs=("${service_name}-probe-scheduler" "${service_name}-precheck-scheduler")' in cleanup_section assert "if: steps.replacements.outputs.ready == 'true'" in cleanup_section + + +def test_existing_scheduler_cron_rejects_ambiguous_day_fields() -> None: + workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + + assert 'if current_fields[2] != "*" and current_fields[4] != "*":' in workflow + assert "cannot constrain both day-of-month and day-of-week" in workflow From 67a3713f51c00aab69e299e60926bafdc4e1f31c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:26:27 +0800 Subject: [PATCH 5/6] fix: preserve monitor fallback during migration Co-Authored-By: Codex --- .github/workflows/sync-cloud-run-env.yml | 4 ++-- tests/test_scheduler_workflow.py | 11 +++++++++++ tests/test_sync_cloud_run_env_workflow.sh | 1 - 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 7a8df61..5c904c1 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -861,8 +861,6 @@ jobs: remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS") fi - remove_env_vars+=("MONITOR_DISPATCH_TARGETS_JSON" "LONGBRIDGE_MONITOR_DISPATCH_TARGETS_JSON") - gcloud_args=( run services update "${CLOUD_RUN_SERVICE}" --region "${CLOUD_RUN_REGION}" @@ -1315,6 +1313,8 @@ jobs: or scheduler.get("location") or target.get("region") or target.get("cloud_run_region") + or os.environ.get("CLOUD_SCHEDULER_LOCATION") + or os.environ.get("CLOUD_RUN_REGION") or "" ).strip() if service_name: diff --git a/tests/test_scheduler_workflow.py b/tests/test_scheduler_workflow.py index 131435a..584b7f0 100644 --- a/tests/test_scheduler_workflow.py +++ b/tests/test_scheduler_workflow.py @@ -13,10 +13,21 @@ def test_replacement_probe_and_precheck_exist_before_shared_dispatcher_cleanup() assert precheck < cleanup_job < cleanup cleanup_section = workflow[cleanup_job:] + sync_section = workflow[:cleanup_job] assert "needs: sync" in cleanup_section assert "environment: longbridge-sg" in cleanup_section assert 'replacement_jobs=("${service_name}-probe-scheduler" "${service_name}-precheck-scheduler")' in cleanup_section assert "if: steps.replacements.outputs.ready == 'true'" in cleanup_section + assert "MONITOR_DISPATCH_TARGETS_JSON" not in sync_section + assert "LONGBRIDGE_MONITOR_DISPATCH_TARGETS_JSON" not in sync_section + + +def test_replacement_verifier_falls_back_to_global_scheduler_location() -> None: + workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + cleanup_section = workflow[workflow.index("cleanup-shared-monitor:") :] + + assert 'or os.environ.get("CLOUD_SCHEDULER_LOCATION")' in cleanup_section + assert 'or os.environ.get("CLOUD_RUN_REGION")' in cleanup_section def test_existing_scheduler_cron_rejects_ambiguous_day_fields() -> None: diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index ab660ed..b5e8321 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -209,7 +209,6 @@ grep -Fq 'join_by_delimiter()' "$workflow_file" grep -Fq 'gcloud_args+=(--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")")' "$workflow_file" grep -Fq 'gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")")' "$workflow_file" grep -Fq -- '--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")"' "$workflow_file" -grep -Fq 'remove_env_vars+=("MONITOR_DISPATCH_TARGETS_JSON" "LONGBRIDGE_MONITOR_DISPATCH_TARGETS_JSON")' "$workflow_file" grep -Fq 'Sync Cloud Scheduler schedule' "$workflow_file" grep -Fq 'scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}"' "$workflow_file" grep -Fq 'target_env = target.get("env") or {}' "$workflow_file" From 511b78a652cba624b1ec49943938f2db211d38c9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:32:03 +0800 Subject: [PATCH 6/6] fix: support array scheduler targets Co-Authored-By: Codex --- .github/workflows/sync-cloud-run-env.yml | 8 +++++++- tests/test_scheduler_workflow.py | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 5c904c1..9c70cb1 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -1303,7 +1303,13 @@ jobs: import os payload = json.loads(os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "{}") - for target in payload.get("targets") or []: + if isinstance(payload, list): + targets = payload + elif isinstance(payload, dict): + targets = payload.get("targets") or [] + else: + targets = [] + for target in targets: if not isinstance(target, dict): continue service_name = str(target.get("service_name") or target.get("service") or "").strip() diff --git a/tests/test_scheduler_workflow.py b/tests/test_scheduler_workflow.py index 584b7f0..1750b40 100644 --- a/tests/test_scheduler_workflow.py +++ b/tests/test_scheduler_workflow.py @@ -26,6 +26,9 @@ def test_replacement_verifier_falls_back_to_global_scheduler_location() -> None: workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") cleanup_section = workflow[workflow.index("cleanup-shared-monitor:") :] + assert "if isinstance(payload, list):" in cleanup_section + assert "elif isinstance(payload, dict):" in cleanup_section + assert "for target in targets:" in cleanup_section assert 'or os.environ.get("CLOUD_SCHEDULER_LOCATION")' in cleanup_section assert 'or os.environ.get("CLOUD_RUN_REGION")' in cleanup_section