From 3af363a9521508b5d18462dc25d6ee4b8896666c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:38:22 +0900 Subject: [PATCH] ci: protect remaining workflow JSON env headers with reusable secrets --- ...lect-reconciliation-evidence-protected.yml | 405 ++++ .../collect-reconciliation-evidence.yml | 393 +--- .../env-sync-drift-check-protected.yml | 218 +++ .github/workflows/env-sync-drift-check.yml | 182 +- .../execution-report-heartbeat-protected.yml | 135 ++ .../workflows/execution-report-heartbeat.yml | 88 +- .../notify-route-check-protected.yml | 53 + .github/workflows/notify-route-check.yml | 28 +- .github/workflows/runtime-guard-protected.yml | 117 ++ .github/workflows/runtime-guard.yml | 79 +- .../sync-cloud-run-env-protected.yml | 1641 +++++++++++++++++ .github/workflows/sync-cloud-run-env.yml | 1577 +--------------- scripts/env_sync_drift_check.py | 4 +- tests/test_cloud_run_warmup_workflow.py | 15 +- tests/test_env_sync_drift_check.py | 14 +- tests/test_paper_notification_preview.py | 2 + .../test_reconciliation_evidence_workflow.py | 2 +- tests/test_runtime_monitor_workflows.py | 22 +- .../test_safe_no_traffic_deploy_isolation.py | 15 +- tests/test_scheduler_deadline_contract.py | 2 +- tests/test_sync_cloud_run_env_workflow.sh | 17 +- tests/test_sync_cloud_run_source_commit.py | 2 +- tests/test_uv_dependency_workflow.py | 12 +- .../test_verify_image_ues_release_binding.py | 2 +- tests/test_workflow_json_secret_boundary.py | 151 ++ 25 files changed, 2847 insertions(+), 2329 deletions(-) create mode 100644 .github/workflows/collect-reconciliation-evidence-protected.yml create mode 100644 .github/workflows/env-sync-drift-check-protected.yml create mode 100644 .github/workflows/execution-report-heartbeat-protected.yml create mode 100644 .github/workflows/notify-route-check-protected.yml create mode 100644 .github/workflows/runtime-guard-protected.yml create mode 100644 .github/workflows/sync-cloud-run-env-protected.yml create mode 100644 tests/test_workflow_json_secret_boundary.py diff --git a/.github/workflows/collect-reconciliation-evidence-protected.yml b/.github/workflows/collect-reconciliation-evidence-protected.yml new file mode 100644 index 00000000..7779c64e --- /dev/null +++ b/.github/workflows/collect-reconciliation-evidence-protected.yml @@ -0,0 +1,405 @@ +name: Collect IBKR Reconciliation Evidence (protected execution) + +# Register the original JSON as a workflow secret before any runner step header. +# This masks the passed value, not every extracted field or reserialized variant. +on: + workflow_call: + inputs: + target: + type: string + required: false + inspect_execution_ledger: + type: boolean + required: false + secrets: + CLOUD_RUN_SERVICE_TARGETS_JSON: + required: false + +permissions: + contents: read + id-token: write + +env: + GCP_PROJECT_ID: interactivebrokersquant + GCP_REGION: us-central1 + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + GCP_SCHEDULER_SERVICE_ACCOUNT: ibkr-platform-scheduler@interactivebrokersquant.iam.gserviceaccount.com + +jobs: + resolve-matrix: + name: Resolve reconciliation matrix + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.render.outputs.matrix }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Resolve reconciliation profiles from private inventory + id: render + env: + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + run: | + set -euo pipefail + python3 scripts/render_runtime_target_matrix.py --profile reconciliation --private-config --github-output + + collect: + needs: resolve-matrix + name: Read ${{ matrix.profile }} baseline evidence + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.resolve-matrix.outputs.matrix) }} + steps: + - name: Select requested target + id: selection + env: + REQUESTED_TARGET: ${{ inputs.target }} + PROFILE: ${{ matrix.profile }} + run: | + set -euo pipefail + if [ "$REQUESTED_TARGET" = "all" ] || [ "$REQUESTED_TARGET" = "$PROFILE" ]; then + echo "selected=true" >> "$GITHUB_OUTPUT" + else + echo "selected=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout for optional execution ledger diagnostic + if: ${{ steps.selection.outputs.selected == 'true' && inputs.inspect_execution_ledger }} + uses: actions/checkout@v6 + + - name: Resolve selected service from private inventory + id: runtime_target + if: ${{ steps.selection.outputs.selected == 'true' }} + env: + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + PROFILE: ${{ matrix.profile }} + run: | + python3 - <<'PY' + import json + import os + + payload = json.loads(os.environ["CLOUD_RUN_SERVICE_TARGETS_JSON"]) + entries = payload.get("targets") if isinstance(payload, dict) else payload + matches = [] + for item in entries: + if not isinstance(item, dict) or item.get("include_reconciliation") is not True: + continue + runtime = item.get("runtime_target") or {} + if isinstance(runtime, str): + runtime = json.loads(runtime) + if runtime.get("strategy_profile") == os.environ["PROFILE"]: + service = str(item.get("service") or item.get("service_name") or "").strip() + if service and runtime.get("service_name") == service: + matches.append(service) + if len(matches) != 1: + raise SystemExit("expected exactly one private reconciliation service") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle: + handle.write(f"service={matches[0]}\n") + PY + + - name: Authenticate to Google Cloud + if: ${{ steps.selection.outputs.selected == 'true' }} + 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 + if: ${{ steps.selection.outputs.selected == 'true' }} + uses: google-github-actions/setup-gcloud@v3 + + - name: Resolve Cloud Run audience + id: audience + if: ${{ steps.selection.outputs.selected == 'true' }} + env: + SERVICE: ${{ steps.runtime_target.outputs.service }} + run: | + set -euo pipefail + service_url="$(gcloud run services describe "$SERVICE" --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --format='value(status.url)')" + test -n "$service_url" + echo "service_url=$service_url" >> "$GITHUB_OUTPUT" + + - name: Schedule one-shot internal reconciliation request + id: scheduler + if: ${{ steps.selection.outputs.selected == 'true' }} + env: + PROFILE: ${{ matrix.profile }} + SERVICE_URL: ${{ steps.audience.outputs.service_url }} + run: | + set -euo pipefail + test -n "$SERVICE_URL" + job_name="ibkr-reconcile-${PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + request_id="$(python -c 'import uuid; print(uuid.uuid4())')" + requested_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + case "$PROFILE" in + soxl_soxx_trend_income) delay_minutes=3 ;; + tqqq_growth_income) delay_minutes=5 ;; + global_etf_rotation) delay_minutes=7 ;; + russell_top50_leader_rotation) delay_minutes=9 ;; + *) + echo "Unsupported reconciliation profile: $PROFILE" >&2 + exit 1 + ;; + esac + schedule="$(date -u -d "+${delay_minutes} minutes" '+%M %H %d %m *')" + if gcloud scheduler jobs describe "$job_name" --project "$GCP_PROJECT_ID" --location "$GCP_REGION" >/dev/null 2>&1; then + echo "Refusing to replace an existing reconciliation job: $job_name" >&2 + exit 1 + fi + gcloud scheduler jobs create http "$job_name" \ + --project "$GCP_PROJECT_ID" \ + --location "$GCP_REGION" \ + --schedule "$schedule" \ + --time-zone 'Etc/UTC' \ + --uri "${SERVICE_URL}/reconcile" \ + --http-method POST \ + --headers "X-QSL-Reconciliation-Request-Id=${request_id}" \ + --oidc-service-account-email "$GCP_SCHEDULER_SERVICE_ACCOUNT" \ + --oidc-token-audience "$SERVICE_URL" \ + --attempt-deadline '300s' \ + --max-retry-attempts=0 \ + --max-retry-duration=0s \ + --quiet >/dev/null + scheduler_job_config="$(gcloud scheduler jobs describe "$job_name" --project "$GCP_PROJECT_ID" --location "$GCP_REGION" --format=json)" + jq -e --arg expected "projects/${GCP_PROJECT_ID}/locations/${GCP_REGION}/jobs/${job_name}" '.name == $expected' <<< "$scheduler_job_config" >/dev/null + configured_request_id="$(jq -er '.httpTarget.headers["X-QSL-Reconciliation-Request-Id"] | select(type == "string" and length > 0)' <<< "$scheduler_job_config")" + test "$configured_request_id" = "$request_id" + scheduler_job_sha256="$(printf '%s' "$job_name" | sha256sum | awk '{print $1}')" + test "${#scheduler_job_sha256}" -eq 64 + { + echo "job_name=$job_name" + echo "request_id=$request_id" + echo "scheduler_job_sha256=$scheduler_job_sha256" + echo "requested_at=$requested_at" + } >> "$GITHUB_OUTPUT" + + # This provenance binds the report-serving revision and its image deploy labels only; + # it does not prove scheduler correlation or that this workflow run created the revision. + - name: Read private no-order reconciliation candidate + id: receipt + if: ${{ steps.selection.outputs.selected == 'true' }} + env: + PROFILE: ${{ matrix.profile }} + SERVICE: ${{ steps.runtime_target.outputs.service }} + SCHEDULER_JOB_SHA256: ${{ steps.scheduler.outputs.scheduler_job_sha256 }} + REQUESTED_AT: ${{ steps.scheduler.outputs.requested_at }} + run: | + set -euo pipefail + test -n "$SCHEDULER_JOB_SHA256" + test -n "$REQUESTED_AT" + mkdir -p reports + log_entry='' + logging_read_failures=0 + receipt_marker_seen=false + for _ in $(seq 1 96); do + logging_output='' + logging_read_status=0 + logging_output="$(gcloud logging read "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"${SERVICE}\" AND timestamp>=\"${REQUESTED_AT}\" AND textPayload:\"reconciliation_receipt_ready\"" --project "$GCP_PROJECT_ID" --freshness=15m --limit=10 --format=json 2>/dev/null)" || logging_read_status=$? + if [ "$logging_read_status" -ne 0 ]; then + logging_read_failures=$((logging_read_failures + 1)) + sleep 5 + continue + fi + if ! jq -e . >/dev/null <<< "$logging_output"; then + echo "reconciliation_log_query_failed class=invalid_logging_json" >&2 + exit 1 + fi + if jq -e '[.[] | select(.textPayload? | type == "string") | select(.textPayload | startswith("reconciliation_receipt_ready "))] | length > 0' >/dev/null <<< "$logging_output"; then + receipt_marker_seen=true + fi + log_entry="$(jq -c --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" 'first(.[] | select(.textPayload? | type == "string") | select(.textPayload | startswith("reconciliation_receipt_ready scheduler_job_sha256=" + $scheduler_job_sha256 + " report_uri=gs://")) | select(.resource.labels.revision_name? | type == "string" and length > 0)) // empty' <<< "$logging_output")" + if [ -n "$log_entry" ]; then + break + fi + sleep 5 + done + if [ -z "$log_entry" ]; then + if [ "$logging_read_failures" -eq 96 ]; then + echo "reconciliation_log_query_failed class=logging_read_nonzero_exit" >&2 + elif [ "$logging_read_failures" -gt 0 ]; then + echo "reconciliation_log_query_failed class=mixed_logging_read_failures_no_candidate" >&2 + elif [ "$receipt_marker_seen" = true ]; then + echo "reconciliation_log_query_timeout class=receipt_scheduler_job_mismatch" >&2 + else + echo "reconciliation_log_query_timeout class=no_matching_candidate" >&2 + fi + exit 1 + fi + receipt="$(jq -cer --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" '.textPayload | capture("^reconciliation_receipt_ready scheduler_job_sha256=(?[0-9a-f]{64}) report_uri=(?gs://[^[:space:]]+)$") | select(.scheduler_job_sha256 == $scheduler_job_sha256)' <<< "$log_entry")" + report_uri="$(jq -er '.uri' <<< "$receipt")" + serving_revision="$(jq -er '.resource.labels.revision_name | select(type == "string" and length > 0)' <<< "$log_entry")" + revision_labels="$(gcloud run revisions describe "$serving_revision" --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --format='json(metadata.labels)')" + service_revision_commit_sha="$(jq -er '.metadata.labels."commit-sha" | select(type == "string" and test("^[0-9a-f]{40}$"))' <<< "$revision_labels")" + service_deploy_run_id="$(jq -er '.metadata.labels."github-run-id" | select(type == "string" and test("^[0-9]+$"))' <<< "$revision_labels")" + gcloud storage cat "$report_uri" | jq --arg profile "$PROFILE" --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" ' + .diagnostics.broker_reconciliation as $candidate + | { + schema_version: "ibkr_reconciliation_artifact.v1", + strategy_profile: $profile, + reconciliation_scheduler_job_sha256: .diagnostics.reconciliation_scheduler_job_sha256, + report_status: .status, + reconciliation: $candidate, + errors: [ + .errors[]? | { + stage, + error_type, + failure_category + } + ] + } + ' > "reports/${PROFILE}.json" + jq -e --arg profile "$PROFILE" --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" ' + .schema_version == "ibkr_reconciliation_artifact.v1" + and .strategy_profile == $profile + and .reconciliation_scheduler_job_sha256 == $scheduler_job_sha256 + and .reconciliation.schema_version == "ibkr_reconciliation_candidate.v1" + and .reconciliation.evidence.platform_id == "ibkr" + and .reconciliation.evidence.strategy_profile == $profile + and (.reconciliation.recovery_blockers | type == "array") + ' "reports/${PROFILE}.json" >/dev/null + evidence_sha256="$(sha256sum "reports/${PROFILE}.json" | awk '{print $1}')" + test "${#evidence_sha256}" -eq 64 + { + echo "evidence_sha256=$evidence_sha256" + echo "serving_revision=$serving_revision" + echo "service_revision_commit_sha=$service_revision_commit_sha" + echo "service_deploy_run_id=$service_deploy_run_id" + } >> "$GITHUB_OUTPUT" + jq -r '[.reconciliation.permits_active_lkg, .reconciliation.expected_digests_configured, (.reconciliation.recovery_blockers | length)] | @tsv' \ + "reports/${PROFILE}.json" | awk -F '\t' '{print "candidate_collected permits_active_lkg=" $1 ", expected_digests_configured=" $2 ", blockers=" $3}' + + - name: Delete temporary internal reconciliation job + if: ${{ always() && steps.selection.outputs.selected == 'true' }} + env: + SCHEDULER_JOB: ${{ steps.scheduler.outputs.job_name }} + run: | + set -euo pipefail + if [ -n "$SCHEDULER_JOB" ]; then + gcloud scheduler jobs delete "$SCHEDULER_JOB" --project "$GCP_PROJECT_ID" --location "$GCP_REGION" --quiet + fi + + - name: Retain redacted candidate + id: receipt_artifact + if: ${{ success() && steps.selection.outputs.selected == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ibkr-reconciliation-${{ matrix.profile }}-${{ github.run_id }} + path: reports/${{ matrix.profile }}.json + if-no-files-found: error + retention-days: 30 + + - name: Generate reconciliation source record + if: ${{ success() && steps.selection.outputs.selected == 'true' }} + env: + ARTIFACT_ID: ${{ steps.receipt_artifact.outputs.artifact-id }} + ARTIFACT_SHA256: ${{ steps.receipt_artifact.outputs.artifact-digest }} + EVIDENCE_SHA256: ${{ steps.receipt.outputs.evidence_sha256 }} + REPOSITORY: ${{ github.repository }} + WORKFLOW_REF: ${{ github.workflow_ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + WORKFLOW_HEAD_SHA: ${{ github.sha }} + PROFILE: ${{ matrix.profile }} + SERVICE: ${{ steps.runtime_target.outputs.service }} + SERVICE_REVISION: ${{ steps.receipt.outputs.serving_revision }} + SERVICE_REVISION_COMMIT_SHA: ${{ steps.receipt.outputs.service_revision_commit_sha }} + SERVICE_DEPLOY_RUN_ID: ${{ steps.receipt.outputs.service_deploy_run_id }} + SCHEDULER_JOB_SHA256: ${{ steps.scheduler.outputs.scheduler_job_sha256 }} + run: | + set -euo pipefail + test "$REPOSITORY" = 'QuantStrategyLab/InteractiveBrokersPlatform' + test "$WORKFLOW_REF" = "${REPOSITORY}/.github/workflows/collect-reconciliation-evidence.yml@refs/heads/main" + test "$WORKFLOW_SHA" = "$WORKFLOW_HEAD_SHA" + test -n "$ARTIFACT_ID" + test "$ARTIFACT_SHA256" != "$EVIDENCE_SHA256" + for digest in "$ARTIFACT_SHA256" "$EVIDENCE_SHA256"; do + test "${#digest}" -eq 64 + case "$digest" in *[!0-9a-f]*) exit 1 ;; esac + done + for sha in "$WORKFLOW_HEAD_SHA" "$SERVICE_REVISION_COMMIT_SHA"; do + test "${#sha}" -eq 40 + case "$sha" in *[!0-9a-f]*) exit 1 ;; esac + done + test "${#SCHEDULER_JOB_SHA256}" -eq 64 + case "$SCHEDULER_JOB_SHA256" in *[!0-9a-f]*) exit 1 ;; esac + mkdir -p source-records + source_record="source-records/${PROFILE}.json" + jq -n \ + --arg schema_version 'ibkr_reconciliation_source_receipt_record.v1' \ + --arg repository "$REPOSITORY" \ + --arg workflow_path '.github/workflows/collect-reconciliation-evidence.yml' \ + --arg workflow_run_id "$WORKFLOW_RUN_ID" \ + --arg workflow_run_attempt "$WORKFLOW_RUN_ATTEMPT" \ + --arg workflow_head_sha "$WORKFLOW_HEAD_SHA" \ + --arg artifact_id "$ARTIFACT_ID" \ + --arg artifact_name "ibkr-reconciliation-${PROFILE}-${WORKFLOW_RUN_ID}" \ + --arg artifact_sha256 "$ARTIFACT_SHA256" \ + --arg evidence_sha256 "$EVIDENCE_SHA256" \ + --arg service_name "$SERVICE" \ + --arg service_revision "$SERVICE_REVISION" \ + --arg service_revision_commit_sha "$SERVICE_REVISION_COMMIT_SHA" \ + --arg service_deploy_run_id "$SERVICE_DEPLOY_RUN_ID" \ + --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" \ + '{ + schema_version: $schema_version, + repository: $repository, + workflow_path: $workflow_path, + workflow_run_id: $workflow_run_id, + workflow_run_attempt: $workflow_run_attempt, + workflow_head_sha: $workflow_head_sha, + artifact_id: $artifact_id, + artifact_name: $artifact_name, + artifact_sha256: $artifact_sha256, + evidence_sha256: $evidence_sha256, + service_name: $service_name, + service_revision: $service_revision, + service_revision_commit_sha: $service_revision_commit_sha, + service_deploy_run_id: $service_deploy_run_id, + scheduler_job_sha256: $scheduler_job_sha256 + }' > "$source_record" + jq -e ' + (keys | sort) == [ + "artifact_id", + "artifact_name", + "artifact_sha256", + "evidence_sha256", + "repository", + "scheduler_job_sha256", + "schema_version", + "service_deploy_run_id", + "service_name", + "service_revision", + "service_revision_commit_sha", + "workflow_head_sha", + "workflow_path", + "workflow_run_attempt", + "workflow_run_id" + ] + and all(.[]; type == "string" and length > 0) + ' "$source_record" >/dev/null + + - name: Upload reconciliation source record + id: source_record_artifact + if: ${{ success() && steps.selection.outputs.selected == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ibkr-reconciliation-source-record-${{ matrix.profile }}-${{ github.run_id }} + path: source-records/${{ matrix.profile }}.json + if-no-files-found: error + retention-days: 30 + + - name: Summarize scoped execution record types + if: ${{ success() && steps.selection.outputs.selected == 'true' && inputs.inspect_execution_ledger }} + env: + SERVICE: ${{ steps.runtime_target.outputs.service }} + run: | + set -euo pipefail + python3 scripts/summarize_execution_ledger_cloud.py \ + --service "$SERVICE" \ + --project "$GCP_PROJECT_ID" \ + --region "$GCP_REGION" diff --git a/.github/workflows/collect-reconciliation-evidence.yml b/.github/workflows/collect-reconciliation-evidence.yml index 71f7cb9a..2ca163b0 100644 --- a/.github/workflows/collect-reconciliation-evidence.yml +++ b/.github/workflows/collect-reconciliation-evidence.yml @@ -24,391 +24,18 @@ permissions: contents: read id-token: write -env: - GCP_PROJECT_ID: interactivebrokersquant - GCP_REGION: us-central1 - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com - GCP_SCHEDULER_SERVICE_ACCOUNT: ibkr-platform-scheduler@interactivebrokersquant.iam.gserviceaccount.com - concurrency: group: ibkr-reconciliation-evidence-${{ inputs.target }} cancel-in-progress: false jobs: - resolve-matrix: - name: Resolve reconciliation matrix - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - matrix: ${{ steps.render.outputs.matrix }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Resolve reconciliation profiles from private inventory - id: render - env: - CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} - run: | - set -euo pipefail - python3 scripts/render_runtime_target_matrix.py --profile reconciliation --private-config --github-output - - collect: - needs: resolve-matrix - name: Read ${{ matrix.profile }} baseline evidence - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.resolve-matrix.outputs.matrix) }} - steps: - - name: Select requested target - id: selection - env: - REQUESTED_TARGET: ${{ inputs.target }} - PROFILE: ${{ matrix.profile }} - run: | - set -euo pipefail - if [ "$REQUESTED_TARGET" = "all" ] || [ "$REQUESTED_TARGET" = "$PROFILE" ]; then - echo "selected=true" >> "$GITHUB_OUTPUT" - else - echo "selected=false" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout for optional execution ledger diagnostic - if: ${{ steps.selection.outputs.selected == 'true' && inputs.inspect_execution_ledger }} - uses: actions/checkout@v6 - - - name: Resolve selected service from private inventory - id: runtime_target - if: ${{ steps.selection.outputs.selected == 'true' }} - env: - CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} - PROFILE: ${{ matrix.profile }} - run: | - python3 - <<'PY' - import json - import os - - payload = json.loads(os.environ["CLOUD_RUN_SERVICE_TARGETS_JSON"]) - entries = payload.get("targets") if isinstance(payload, dict) else payload - matches = [] - for item in entries: - if not isinstance(item, dict) or item.get("include_reconciliation") is not True: - continue - runtime = item.get("runtime_target") or {} - if isinstance(runtime, str): - runtime = json.loads(runtime) - if runtime.get("strategy_profile") == os.environ["PROFILE"]: - service = str(item.get("service") or item.get("service_name") or "").strip() - if service and runtime.get("service_name") == service: - matches.append(service) - if len(matches) != 1: - raise SystemExit("expected exactly one private reconciliation service") - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle: - handle.write(f"service={matches[0]}\n") - PY - - - name: Authenticate to Google Cloud - if: ${{ steps.selection.outputs.selected == 'true' }} - 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 - if: ${{ steps.selection.outputs.selected == 'true' }} - uses: google-github-actions/setup-gcloud@v3 - - - name: Resolve Cloud Run audience - id: audience - if: ${{ steps.selection.outputs.selected == 'true' }} - env: - SERVICE: ${{ steps.runtime_target.outputs.service }} - run: | - set -euo pipefail - service_url="$(gcloud run services describe "$SERVICE" --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --format='value(status.url)')" - test -n "$service_url" - echo "service_url=$service_url" >> "$GITHUB_OUTPUT" - - - name: Schedule one-shot internal reconciliation request - id: scheduler - if: ${{ steps.selection.outputs.selected == 'true' }} - env: - PROFILE: ${{ matrix.profile }} - SERVICE_URL: ${{ steps.audience.outputs.service_url }} - run: | - set -euo pipefail - test -n "$SERVICE_URL" - job_name="ibkr-reconcile-${PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - request_id="$(python -c 'import uuid; print(uuid.uuid4())')" - requested_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - case "$PROFILE" in - soxl_soxx_trend_income) delay_minutes=3 ;; - tqqq_growth_income) delay_minutes=5 ;; - global_etf_rotation) delay_minutes=7 ;; - russell_top50_leader_rotation) delay_minutes=9 ;; - *) - echo "Unsupported reconciliation profile: $PROFILE" >&2 - exit 1 - ;; - esac - schedule="$(date -u -d "+${delay_minutes} minutes" '+%M %H %d %m *')" - if gcloud scheduler jobs describe "$job_name" --project "$GCP_PROJECT_ID" --location "$GCP_REGION" >/dev/null 2>&1; then - echo "Refusing to replace an existing reconciliation job: $job_name" >&2 - exit 1 - fi - gcloud scheduler jobs create http "$job_name" \ - --project "$GCP_PROJECT_ID" \ - --location "$GCP_REGION" \ - --schedule "$schedule" \ - --time-zone 'Etc/UTC' \ - --uri "${SERVICE_URL}/reconcile" \ - --http-method POST \ - --headers "X-QSL-Reconciliation-Request-Id=${request_id}" \ - --oidc-service-account-email "$GCP_SCHEDULER_SERVICE_ACCOUNT" \ - --oidc-token-audience "$SERVICE_URL" \ - --attempt-deadline '300s' \ - --max-retry-attempts=0 \ - --max-retry-duration=0s \ - --quiet >/dev/null - scheduler_job_config="$(gcloud scheduler jobs describe "$job_name" --project "$GCP_PROJECT_ID" --location "$GCP_REGION" --format=json)" - jq -e --arg expected "projects/${GCP_PROJECT_ID}/locations/${GCP_REGION}/jobs/${job_name}" '.name == $expected' <<< "$scheduler_job_config" >/dev/null - configured_request_id="$(jq -er '.httpTarget.headers["X-QSL-Reconciliation-Request-Id"] | select(type == "string" and length > 0)' <<< "$scheduler_job_config")" - test "$configured_request_id" = "$request_id" - scheduler_job_sha256="$(printf '%s' "$job_name" | sha256sum | awk '{print $1}')" - test "${#scheduler_job_sha256}" -eq 64 - { - echo "job_name=$job_name" - echo "request_id=$request_id" - echo "scheduler_job_sha256=$scheduler_job_sha256" - echo "requested_at=$requested_at" - } >> "$GITHUB_OUTPUT" - - # This provenance binds the report-serving revision and its image deploy labels only; - # it does not prove scheduler correlation or that this workflow run created the revision. - - name: Read private no-order reconciliation candidate - id: receipt - if: ${{ steps.selection.outputs.selected == 'true' }} - env: - PROFILE: ${{ matrix.profile }} - SERVICE: ${{ steps.runtime_target.outputs.service }} - SCHEDULER_JOB_SHA256: ${{ steps.scheduler.outputs.scheduler_job_sha256 }} - REQUESTED_AT: ${{ steps.scheduler.outputs.requested_at }} - run: | - set -euo pipefail - test -n "$SCHEDULER_JOB_SHA256" - test -n "$REQUESTED_AT" - mkdir -p reports - log_entry='' - logging_read_failures=0 - receipt_marker_seen=false - for _ in $(seq 1 96); do - logging_output='' - logging_read_status=0 - logging_output="$(gcloud logging read "resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"${SERVICE}\" AND timestamp>=\"${REQUESTED_AT}\" AND textPayload:\"reconciliation_receipt_ready\"" --project "$GCP_PROJECT_ID" --freshness=15m --limit=10 --format=json 2>/dev/null)" || logging_read_status=$? - if [ "$logging_read_status" -ne 0 ]; then - logging_read_failures=$((logging_read_failures + 1)) - sleep 5 - continue - fi - if ! jq -e . >/dev/null <<< "$logging_output"; then - echo "reconciliation_log_query_failed class=invalid_logging_json" >&2 - exit 1 - fi - if jq -e '[.[] | select(.textPayload? | type == "string") | select(.textPayload | startswith("reconciliation_receipt_ready "))] | length > 0' >/dev/null <<< "$logging_output"; then - receipt_marker_seen=true - fi - log_entry="$(jq -c --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" 'first(.[] | select(.textPayload? | type == "string") | select(.textPayload | startswith("reconciliation_receipt_ready scheduler_job_sha256=" + $scheduler_job_sha256 + " report_uri=gs://")) | select(.resource.labels.revision_name? | type == "string" and length > 0)) // empty' <<< "$logging_output")" - if [ -n "$log_entry" ]; then - break - fi - sleep 5 - done - if [ -z "$log_entry" ]; then - if [ "$logging_read_failures" -eq 96 ]; then - echo "reconciliation_log_query_failed class=logging_read_nonzero_exit" >&2 - elif [ "$logging_read_failures" -gt 0 ]; then - echo "reconciliation_log_query_failed class=mixed_logging_read_failures_no_candidate" >&2 - elif [ "$receipt_marker_seen" = true ]; then - echo "reconciliation_log_query_timeout class=receipt_scheduler_job_mismatch" >&2 - else - echo "reconciliation_log_query_timeout class=no_matching_candidate" >&2 - fi - exit 1 - fi - receipt="$(jq -cer --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" '.textPayload | capture("^reconciliation_receipt_ready scheduler_job_sha256=(?[0-9a-f]{64}) report_uri=(?gs://[^[:space:]]+)$") | select(.scheduler_job_sha256 == $scheduler_job_sha256)' <<< "$log_entry")" - report_uri="$(jq -er '.uri' <<< "$receipt")" - serving_revision="$(jq -er '.resource.labels.revision_name | select(type == "string" and length > 0)' <<< "$log_entry")" - revision_labels="$(gcloud run revisions describe "$serving_revision" --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --format='json(metadata.labels)')" - service_revision_commit_sha="$(jq -er '.metadata.labels."commit-sha" | select(type == "string" and test("^[0-9a-f]{40}$"))' <<< "$revision_labels")" - service_deploy_run_id="$(jq -er '.metadata.labels."github-run-id" | select(type == "string" and test("^[0-9]+$"))' <<< "$revision_labels")" - gcloud storage cat "$report_uri" | jq --arg profile "$PROFILE" --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" ' - .diagnostics.broker_reconciliation as $candidate - | { - schema_version: "ibkr_reconciliation_artifact.v1", - strategy_profile: $profile, - reconciliation_scheduler_job_sha256: .diagnostics.reconciliation_scheduler_job_sha256, - report_status: .status, - reconciliation: $candidate, - errors: [ - .errors[]? | { - stage, - error_type, - failure_category - } - ] - } - ' > "reports/${PROFILE}.json" - jq -e --arg profile "$PROFILE" --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" ' - .schema_version == "ibkr_reconciliation_artifact.v1" - and .strategy_profile == $profile - and .reconciliation_scheduler_job_sha256 == $scheduler_job_sha256 - and .reconciliation.schema_version == "ibkr_reconciliation_candidate.v1" - and .reconciliation.evidence.platform_id == "ibkr" - and .reconciliation.evidence.strategy_profile == $profile - and (.reconciliation.recovery_blockers | type == "array") - ' "reports/${PROFILE}.json" >/dev/null - evidence_sha256="$(sha256sum "reports/${PROFILE}.json" | awk '{print $1}')" - test "${#evidence_sha256}" -eq 64 - { - echo "evidence_sha256=$evidence_sha256" - echo "serving_revision=$serving_revision" - echo "service_revision_commit_sha=$service_revision_commit_sha" - echo "service_deploy_run_id=$service_deploy_run_id" - } >> "$GITHUB_OUTPUT" - jq -r '[.reconciliation.permits_active_lkg, .reconciliation.expected_digests_configured, (.reconciliation.recovery_blockers | length)] | @tsv' \ - "reports/${PROFILE}.json" | awk -F '\t' '{print "candidate_collected permits_active_lkg=" $1 ", expected_digests_configured=" $2 ", blockers=" $3}' - - - name: Delete temporary internal reconciliation job - if: ${{ always() && steps.selection.outputs.selected == 'true' }} - env: - SCHEDULER_JOB: ${{ steps.scheduler.outputs.job_name }} - run: | - set -euo pipefail - if [ -n "$SCHEDULER_JOB" ]; then - gcloud scheduler jobs delete "$SCHEDULER_JOB" --project "$GCP_PROJECT_ID" --location "$GCP_REGION" --quiet - fi - - - name: Retain redacted candidate - id: receipt_artifact - if: ${{ success() && steps.selection.outputs.selected == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: ibkr-reconciliation-${{ matrix.profile }}-${{ github.run_id }} - path: reports/${{ matrix.profile }}.json - if-no-files-found: error - retention-days: 30 - - - name: Generate reconciliation source record - if: ${{ success() && steps.selection.outputs.selected == 'true' }} - env: - ARTIFACT_ID: ${{ steps.receipt_artifact.outputs.artifact-id }} - ARTIFACT_SHA256: ${{ steps.receipt_artifact.outputs.artifact-digest }} - EVIDENCE_SHA256: ${{ steps.receipt.outputs.evidence_sha256 }} - REPOSITORY: ${{ github.repository }} - WORKFLOW_REF: ${{ github.workflow_ref }} - WORKFLOW_SHA: ${{ github.workflow_sha }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} - WORKFLOW_HEAD_SHA: ${{ github.sha }} - PROFILE: ${{ matrix.profile }} - SERVICE: ${{ steps.runtime_target.outputs.service }} - SERVICE_REVISION: ${{ steps.receipt.outputs.serving_revision }} - SERVICE_REVISION_COMMIT_SHA: ${{ steps.receipt.outputs.service_revision_commit_sha }} - SERVICE_DEPLOY_RUN_ID: ${{ steps.receipt.outputs.service_deploy_run_id }} - SCHEDULER_JOB_SHA256: ${{ steps.scheduler.outputs.scheduler_job_sha256 }} - run: | - set -euo pipefail - test "$REPOSITORY" = 'QuantStrategyLab/InteractiveBrokersPlatform' - test "$WORKFLOW_REF" = "${REPOSITORY}/.github/workflows/collect-reconciliation-evidence.yml@refs/heads/main" - test "$WORKFLOW_SHA" = "$WORKFLOW_HEAD_SHA" - test -n "$ARTIFACT_ID" - test "$ARTIFACT_SHA256" != "$EVIDENCE_SHA256" - for digest in "$ARTIFACT_SHA256" "$EVIDENCE_SHA256"; do - test "${#digest}" -eq 64 - case "$digest" in *[!0-9a-f]*) exit 1 ;; esac - done - for sha in "$WORKFLOW_HEAD_SHA" "$SERVICE_REVISION_COMMIT_SHA"; do - test "${#sha}" -eq 40 - case "$sha" in *[!0-9a-f]*) exit 1 ;; esac - done - test "${#SCHEDULER_JOB_SHA256}" -eq 64 - case "$SCHEDULER_JOB_SHA256" in *[!0-9a-f]*) exit 1 ;; esac - mkdir -p source-records - source_record="source-records/${PROFILE}.json" - jq -n \ - --arg schema_version 'ibkr_reconciliation_source_receipt_record.v1' \ - --arg repository "$REPOSITORY" \ - --arg workflow_path '.github/workflows/collect-reconciliation-evidence.yml' \ - --arg workflow_run_id "$WORKFLOW_RUN_ID" \ - --arg workflow_run_attempt "$WORKFLOW_RUN_ATTEMPT" \ - --arg workflow_head_sha "$WORKFLOW_HEAD_SHA" \ - --arg artifact_id "$ARTIFACT_ID" \ - --arg artifact_name "ibkr-reconciliation-${PROFILE}-${WORKFLOW_RUN_ID}" \ - --arg artifact_sha256 "$ARTIFACT_SHA256" \ - --arg evidence_sha256 "$EVIDENCE_SHA256" \ - --arg service_name "$SERVICE" \ - --arg service_revision "$SERVICE_REVISION" \ - --arg service_revision_commit_sha "$SERVICE_REVISION_COMMIT_SHA" \ - --arg service_deploy_run_id "$SERVICE_DEPLOY_RUN_ID" \ - --arg scheduler_job_sha256 "$SCHEDULER_JOB_SHA256" \ - '{ - schema_version: $schema_version, - repository: $repository, - workflow_path: $workflow_path, - workflow_run_id: $workflow_run_id, - workflow_run_attempt: $workflow_run_attempt, - workflow_head_sha: $workflow_head_sha, - artifact_id: $artifact_id, - artifact_name: $artifact_name, - artifact_sha256: $artifact_sha256, - evidence_sha256: $evidence_sha256, - service_name: $service_name, - service_revision: $service_revision, - service_revision_commit_sha: $service_revision_commit_sha, - service_deploy_run_id: $service_deploy_run_id, - scheduler_job_sha256: $scheduler_job_sha256 - }' > "$source_record" - jq -e ' - (keys | sort) == [ - "artifact_id", - "artifact_name", - "artifact_sha256", - "evidence_sha256", - "repository", - "scheduler_job_sha256", - "schema_version", - "service_deploy_run_id", - "service_name", - "service_revision", - "service_revision_commit_sha", - "workflow_head_sha", - "workflow_path", - "workflow_run_attempt", - "workflow_run_id" - ] - and all(.[]; type == "string" and length > 0) - ' "$source_record" >/dev/null - - - name: Upload reconciliation source record - id: source_record_artifact - if: ${{ success() && steps.selection.outputs.selected == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: ibkr-reconciliation-source-record-${{ matrix.profile }}-${{ github.run_id }} - path: source-records/${{ matrix.profile }}.json - if-no-files-found: error - retention-days: 30 - - - name: Summarize scoped execution record types - if: ${{ success() && steps.selection.outputs.selected == 'true' && inputs.inspect_execution_ledger }} - env: - SERVICE: ${{ steps.runtime_target.outputs.service }} - run: | - set -euo pipefail - python3 scripts/summarize_execution_ledger_cloud.py \ - --service "$SERVICE" \ - --project "$GCP_PROJECT_ID" \ - --region "$GCP_REGION" + collect-evidence: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/collect-reconciliation-evidence-protected.yml + with: + target: ${{ inputs.target }} + inspect_execution_ledger: ${{ inputs.inspect_execution_ledger }} + secrets: + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} diff --git a/.github/workflows/env-sync-drift-check-protected.yml b/.github/workflows/env-sync-drift-check-protected.yml new file mode 100644 index 00000000..bffbffbd --- /dev/null +++ b/.github/workflows/env-sync-drift-check-protected.yml @@ -0,0 +1,218 @@ +name: Env Sync Drift Check (protected execution) + +# Register the original JSON as a workflow secret before any runner step header. +# This masks the passed value, not every extracted field or reserialized variant. +on: + workflow_call: + inputs: + show_runtime_target_diff: + type: boolean + required: false + secrets: + CLOUD_RUN_SERVICE: + required: false + CLOUD_RUN_SERVICES: + required: false + CLOUD_RUN_SERVICE_TARGETS_JSON: + required: false + GLOBAL_TELEGRAM_CHAT_ID: + required: false + RUNTIME_TARGET_JSON: + required: false + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: + required: false + STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: + required: false + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: + required: false + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: + required: false + TELEGRAM_TOKEN: + required: false + TG_TOKEN: + required: false + +permissions: + contents: read + id-token: write + +env: + GCP_PROJECT_ID: interactivebrokersquant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + GCP_RUNTIME_SERVICE_ACCOUNT: ibkr-platform-runtime@interactivebrokersquant.iam.gserviceaccount.com + GCP_SCHEDULER_SERVICE_ACCOUNT: ibkr-platform-scheduler@interactivebrokersquant.iam.gserviceaccount.com + GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy + +jobs: + env-sync-drift: + name: Env sync drift (read-only) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + env: + ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }} + ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} + ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} + QSL_ENABLE_CLOUD_RUN_AUTOMATION: ${{ vars.QSL_ENABLE_CLOUD_RUN_AUTOMATION }} + CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }} + WORKFLOW_TARGET: configured + INPUT_CONFIGURED_SERVICE: '' + INPUT_SOURCE_COMMIT: '' + SOURCE_COMMIT: ${{ github.sha }} + INPUT_CLOUD_RUN_REGION: '' + INPUT_CLOUD_RUN_SERVICE: '' + INPUT_ACCOUNT_GROUP: '' + INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME: '' + INPUT_DEPLOY_IMAGE: '' + INPUT_SYNC_ENV: '' + INPUT_APPROVE_TRAFFIC_SHIFT: '' + INPUT_APPROVE_SCHEDULER_SYNC: '' + GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} + CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} + CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} + # This is the validated non-secret service inventory maintained by the + # control plane; legacy Secret support is only a migration fallback. + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }} + CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} + 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 }} + TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} + # This is validated deployment intent, not credential material. Prefer the + # control-plane variable while retaining a legacy-secret fallback during migration. + RUNTIME_TARGET_JSON: ${{ secrets.RUNTIME_TARGET_JSON }} + ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }} + IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }} + IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }} + IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }} + IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE }} + IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }} + IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS }} + IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }} + IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }} + IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} + IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} + IBKR_EXECUTION_DEDUP_ENABLED: ${{ vars.IBKR_EXECUTION_DEDUP_ENABLED }} + IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }} + IBKR_FORCE_RUN: ${{ vars.IBKR_FORCE_RUN }} + IBKR_MARKET: ${{ vars.IBKR_MARKET }} + IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} + IBKR_MARKET_CURRENCY: ${{ vars.IBKR_MARKET_CURRENCY }} + IBKR_MARKET_DATA_SYMBOL_SUFFIX: ${{ vars.IBKR_MARKET_DATA_SYMBOL_SUFFIX }} + IBKR_MARKET_EXCHANGE: ${{ vars.IBKR_MARKET_EXCHANGE }} + IBKR_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }} + IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }} + IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }} + IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} + INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} + INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }} + INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} + IBKR_CASH_ONLY_EXECUTION: ${{ vars.IBKR_CASH_ONLY_EXECUTION }} + CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }} + DCA_MODE: ${{ vars.DCA_MODE }} + DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }} + IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }} + IBIT_ZSCORE_EXIT_MODE: ${{ vars.IBIT_ZSCORE_EXIT_MODE }} + IBIT_ZSCORE_EXIT_PARKING_SYMBOL: ${{ vars.IBIT_ZSCORE_EXIT_PARKING_SYMBOL }} + IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }} + IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE }} + IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }} + IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI }} + IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} + IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} + IBKR_MARKET_SIGNAL_CACHE_DIR: ${{ vars.IBKR_MARKET_SIGNAL_CACHE_DIR }} + IBKR_MARKET_SIGNAL_REQUIRED: ${{ vars.IBKR_MARKET_SIGNAL_REQUIRED }} + IBKR_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.IBKR_MARKET_SIGNAL_FALLBACK_MODE }} + IBKR_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.IBKR_MARKET_SIGNAL_MAX_STALE_DAYS }} + STRATEGY_PLUGIN_ALERT_CHANNELS: ${{ vars.STRATEGY_PLUGIN_ALERT_CHANNELS }} + STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS }} + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL }} + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST }} + STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT }} + STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY }} + STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS }} + STRATEGY_PLUGIN_ALERT_SMS_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_PROVIDER }} + STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID }} + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_SMS_SENDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_SENDER }} + STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID }} + STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL }} + STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS }} + STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS }} + STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER }} + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL }} + STRATEGY_PLUGIN_ALERT_PUSH_DEVICE: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_DEVICE }} + STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY }} + STRATEGY_PLUGIN_ALERT_PUSH_TAGS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_TAGS }} + STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS }} + # Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs. + EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} + # Empty by default. When explicitly set, the workflow downloads one + # immutable, private recovery ledger before deriving its sync plan. + IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI: ${{ vars.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }} + GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} + NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD }} + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} + STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.TG_TOKEN }} + DRIFT_PER_TARGET: "1" + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install strategy status dependencies + run: | + set -euo pipefail + python -m pip install --upgrade pip uv + uv sync --frozen --no-dev + + - 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 }} + + - name: Report recovery-ledger opt-in (not fetched) + run: | + if [ -n "${IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI:-}" ]; then + echo "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI is set; this check does not fetch it, so plan may differ from a real sync." >&2 + echo "recovery_ledger_opt_in=true" + else + echo "recovery_ledger_opt_in=false" + fi + + - name: Build sync plan and report drift (key names only) + env: + DRIFT_VALUE_DIFF_KEYS: ${{ inputs.show_runtime_target_diff && 'RUNTIME_TARGET_JSON' || '' }} + run: | + set -euo pipefail + SYNC_PLAN_JSON="$(uv run --no-sync python scripts/build_cloud_run_env_sync_plan.py --json)" + export SYNC_PLAN_JSON + python3 scripts/env_sync_drift_check.py diff --git a/.github/workflows/env-sync-drift-check.yml b/.github/workflows/env-sync-drift-check.yml index bf411155..c32e752a 100644 --- a/.github/workflows/env-sync-drift-check.yml +++ b/.github/workflows/env-sync-drift-check.yml @@ -2,13 +2,13 @@ name: Env Sync Drift Check # Read-only. Answers "what would a full Deploy Cloud Run env sync change?" by # re-running the exact "Sync Cloud Run environment" step script from -# sync-cloud-run-env.yml with its single mutating gcloud call replaced by a +# sync-cloud-run-env-protected.yml with its single mutating gcloud call replaced by a # capture, then diffing against each serving (100%) revision. # Prints key names only (plus Secret Manager reference names for secret-backed # keys); never prints env values, tokens, or chat ids. Never updates Cloud Run, # secrets, IAM, Scheduler, or traffic. # -# The job env below mirrors sync-cloud-run-env.yml (inputs blanked); the test +# The protected job env mirrors sync-cloud-run-env-protected.yml (inputs blanked); the test # tests/test_env_sync_drift_check.py fails if the two drift apart. on: workflow_dispatch: @@ -21,184 +21,24 @@ on: permissions: contents: read -env: - GCP_PROJECT_ID: interactivebrokersquant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com - GCP_RUNTIME_SERVICE_ACCOUNT: ibkr-platform-runtime@interactivebrokersquant.iam.gserviceaccount.com - GCP_SCHEDULER_SERVICE_ACCOUNT: ibkr-platform-scheduler@interactivebrokersquant.iam.gserviceaccount.com - GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy - - jobs: env-sync-drift: name: Env sync drift (read-only) - runs-on: ubuntu-latest - timeout-minutes: 15 permissions: contents: read id-token: write - env: - ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }} - ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} - ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} - QSL_ENABLE_CLOUD_RUN_AUTOMATION: ${{ vars.QSL_ENABLE_CLOUD_RUN_AUTOMATION }} - CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }} - WORKFLOW_TARGET: configured - INPUT_CONFIGURED_SERVICE: '' - INPUT_SOURCE_COMMIT: '' - SOURCE_COMMIT: ${{ github.sha }} - INPUT_CLOUD_RUN_REGION: '' - INPUT_CLOUD_RUN_SERVICE: '' - INPUT_ACCOUNT_GROUP: '' - INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME: '' - INPUT_DEPLOY_IMAGE: '' - INPUT_SYNC_ENV: '' - INPUT_APPROVE_TRAFFIC_SHIFT: '' - INPUT_APPROVE_SCHEDULER_SYNC: '' - GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} - CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + uses: ./.github/workflows/env-sync-drift-check-protected.yml + with: + show_runtime_target_diff: ${{ inputs.show_runtime_target_diff }} + secrets: CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} - # This is the validated non-secret service inventory maintained by the - # control plane; legacy Secret support is only a migration fallback. CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} - CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }} - CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} - 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 }} - TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} - # This is validated deployment intent, not credential material. Prefer the - # control-plane variable while retaining a legacy-secret fallback during migration. - RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} - ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }} - IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }} - IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }} - IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }} - IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE }} - IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }} - IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS }} - IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }} - IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }} - IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} - IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} - IBKR_EXECUTION_DEDUP_ENABLED: ${{ vars.IBKR_EXECUTION_DEDUP_ENABLED }} - IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }} - IBKR_FORCE_RUN: ${{ vars.IBKR_FORCE_RUN }} - IBKR_MARKET: ${{ vars.IBKR_MARKET }} - IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} - IBKR_MARKET_CURRENCY: ${{ vars.IBKR_MARKET_CURRENCY }} - IBKR_MARKET_DATA_SYMBOL_SUFFIX: ${{ vars.IBKR_MARKET_DATA_SYMBOL_SUFFIX }} - IBKR_MARKET_EXCHANGE: ${{ vars.IBKR_MARKET_EXCHANGE }} - IBKR_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }} - IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }} - IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }} - IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} - INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} - INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }} - INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} - IBKR_CASH_ONLY_EXECUTION: ${{ vars.IBKR_CASH_ONLY_EXECUTION }} - CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }} - DCA_MODE: ${{ vars.DCA_MODE }} - DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }} - IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }} - IBIT_ZSCORE_EXIT_MODE: ${{ vars.IBIT_ZSCORE_EXIT_MODE }} - IBIT_ZSCORE_EXIT_PARKING_SYMBOL: ${{ vars.IBIT_ZSCORE_EXIT_PARKING_SYMBOL }} - IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }} - IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE }} - IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }} - IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI }} - IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} - IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} - IBKR_MARKET_SIGNAL_CACHE_DIR: ${{ vars.IBKR_MARKET_SIGNAL_CACHE_DIR }} - IBKR_MARKET_SIGNAL_REQUIRED: ${{ vars.IBKR_MARKET_SIGNAL_REQUIRED }} - IBKR_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.IBKR_MARKET_SIGNAL_FALLBACK_MODE }} - IBKR_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.IBKR_MARKET_SIGNAL_MAX_STALE_DAYS }} - STRATEGY_PLUGIN_ALERT_CHANNELS: ${{ vars.STRATEGY_PLUGIN_ALERT_CHANNELS }} - STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS }} - STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL }} - STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST }} - STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT }} - STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY }} - STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS }} - STRATEGY_PLUGIN_ALERT_SMS_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_PROVIDER }} - STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID }} - STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_SMS_SENDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_SENDER }} - STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID }} - STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL }} - STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS }} - STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS }} - STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER }} - STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL }} - STRATEGY_PLUGIN_ALERT_PUSH_DEVICE: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_DEVICE }} - STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY }} - STRATEGY_PLUGIN_ALERT_PUSH_TAGS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_TAGS }} - STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS }} - # Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs. - EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} - # Empty by default. When explicitly set, the workflow downloads one - # immutable, private recovery ledger before deriving its sync plan. - IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI: ${{ vars.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }} GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} - NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} - TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD }} - STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} - STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.TG_TOKEN }} - DRIFT_PER_TARGET: "1" - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install strategy status dependencies - run: | - set -euo pipefail - python -m pip install --upgrade pip uv - uv sync --frozen --no-dev - - - 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 }} - - - name: Report recovery-ledger opt-in (not fetched) - run: | - if [ -n "${IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI:-}" ]; then - echo "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI is set; this check does not fetch it, so plan may differ from a real sync." >&2 - echo "recovery_ledger_opt_in=true" - else - echo "recovery_ledger_opt_in=false" - fi - - - name: Build sync plan and report drift (key names only) - env: - DRIFT_VALUE_DIFF_KEYS: ${{ inputs.show_runtime_target_diff && 'RUNTIME_TARGET_JSON' || '' }} - run: | - set -euo pipefail - SYNC_PLAN_JSON="$(uv run --no-sync python scripts/build_cloud_run_env_sync_plan.py --json)" - export SYNC_PLAN_JSON - python3 scripts/env_sync_drift_check.py + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TG_TOKEN: ${{ secrets.TG_TOKEN }} diff --git a/.github/workflows/execution-report-heartbeat-protected.yml b/.github/workflows/execution-report-heartbeat-protected.yml new file mode 100644 index 00000000..9a673d70 --- /dev/null +++ b/.github/workflows/execution-report-heartbeat-protected.yml @@ -0,0 +1,135 @@ +name: Execution Report Heartbeat (protected execution) + +# Register the original JSON as a workflow secret before any runner step header. +# This masks the passed value, not every extracted field or reserialized variant. +on: + workflow_call: + inputs: + lookback_hours: + type: string + required: false + notify_on_success: + type: boolean + required: false + fail_workflow_on_alert: + type: string + required: false + send_daily_dry_run_digest: + type: boolean + required: false + account_facts_target: + type: string + required: false + account_facts_report_name: + type: string + required: false + secrets: + CLOUD_RUN_SERVICE: + required: false + CLOUD_RUN_SERVICES: + required: false + CLOUD_RUN_SERVICE_TARGETS_JSON: + required: false + EXECUTION_EVIDENCE_SYNC_TOKEN: + required: false + GLOBAL_TELEGRAM_CHAT_ID: + required: false + RUNTIME_HEARTBEAT_REQUIRED_SERVICES: + required: false + RUNTIME_TARGET_JSON: + required: false + TELEGRAM_TOKEN: + required: false + +permissions: + contents: read + id-token: write + +jobs: + heartbeat: + name: Check execution report heartbeat + if: ${{ github.event_name != 'workflow_dispatch' || (inputs.account_facts_target != 'ingress-diagnostic' && inputs.account_facts_target != 'paused-refresh-preflight' && inputs.account_facts_target != 'paused-refresh-state-backup' && ((inputs.account_facts_target != 'primary-live' && inputs.account_facts_target != 'additional-1' && inputs.account_facts_target != 'additional-2' && inputs.account_facts_target != 'additional-3') || inputs.account_facts_report_name == '')) }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + env: + GCP_PROJECT_ID: interactivebrokersquant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + RUNTIME_HEARTBEAT_NAME: InteractiveBrokersPlatform + RUNTIME_HEARTBEAT_REPORT_PLATFORM: interactive_brokers + RUNTIME_HEARTBEAT_REQUIRED_SERVICES: ${{ secrets.RUNTIME_HEARTBEAT_REQUIRED_SERVICES }} + RUNTIME_HEARTBEAT_GCS_URIS: ${{ vars.RUNTIME_HEARTBEAT_GCS_URIS || vars.EXECUTION_REPORT_GCS_URI }} + RUNTIME_HEARTBEAT_LOOKBACK_HOURS: ${{ inputs.lookback_hours || vars.RUNTIME_HEARTBEAT_LOOKBACK_HOURS || '36' }} + RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }} + RUNTIME_HEARTBEAT_ACCEPT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_ACCEPT_STATUSES }} + RUNTIME_HEARTBEAT_REJECT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_REJECT_STATUSES }} + RUNTIME_HEARTBEAT_MARKET_AWARE: ${{ vars.RUNTIME_HEARTBEAT_MARKET_AWARE || 'true' }} + RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS: ${{ github.event_name == 'workflow_dispatch' && inputs.notify_on_success && 'true' || 'false' }} + RUNTIME_HEARTBEAT_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} + RUNTIME_HEARTBEAT_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }} + RUNTIME_HEARTBEAT_PUBLICATION_GRACE_MINUTES: ${{ vars.RUNTIME_HEARTBEAT_PUBLICATION_GRACE_MINUTES || '30' }} + RUNTIME_HEARTBEAT_SCHEDULER_AWARE: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_AWARE || 'true' }} + RUNTIME_HEARTBEAT_SCHEDULER_LOCATION: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION || 'us-central1' }} + RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} + RUNTIME_TARGET_JSON: ${{ secrets.RUNTIME_TARGET_JSON }} + CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} + CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + CLOUD_SCHEDULER_MAIN_TIME: ${{ vars.CLOUD_SCHEDULER_MAIN_TIME }} + NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }} + GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + + - name: Install dependencies + run: | + set -euo pipefail + uv sync --frozen --no-dev + + - name: Authenticate to Google Cloud + id: gcp_auth_primary + continue-on-error: true + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + + - name: Wait before Google Cloud authentication retry + if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} + run: sleep 10 + + - name: Retry Google Cloud authentication + if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} + 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 + + - name: Check recent execution report + run: uv run --no-sync python scripts/execution_report_heartbeat.py + + - name: Publish read-only runtime execution evidence + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@3053c2646f391a8025a259cc138e0420e6e7ba54 + with: + source-id: runtime-reports-ibkr + report-platform: interactive_brokers + report-prefix: ${{ vars.RUNTIME_HEARTBEAT_GCS_URIS || vars.EXECUTION_REPORT_GCS_URI }} + sync-url: ${{ vars.EXECUTION_EVIDENCE_SYNC_URL }} + env: + EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }} + + - name: Send configured daily dry-run digest + if: ${{ always() && (github.event_name == 'schedule' || inputs.send_daily_dry_run_digest) }} + run: uv run --no-sync python scripts/daily_dry_run_digest.py diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index e87c883f..3d4540e6 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -66,90 +66,26 @@ jobs: heartbeat: name: Check execution report heartbeat if: ${{ github.event_name != 'workflow_dispatch' || (inputs.account_facts_target != 'ingress-diagnostic' && inputs.account_facts_target != 'paused-refresh-preflight' && inputs.account_facts_target != 'paused-refresh-state-backup' && ((inputs.account_facts_target != 'primary-live' && inputs.account_facts_target != 'additional-1' && inputs.account_facts_target != 'additional-2' && inputs.account_facts_target != 'additional-3') || inputs.account_facts_report_name == '')) }} - runs-on: ubuntu-latest - timeout-minutes: 15 permissions: contents: read id-token: write - env: - GCP_PROJECT_ID: interactivebrokersquant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com - RUNTIME_HEARTBEAT_NAME: InteractiveBrokersPlatform - RUNTIME_HEARTBEAT_REPORT_PLATFORM: interactive_brokers - RUNTIME_HEARTBEAT_REQUIRED_SERVICES: ${{ secrets.RUNTIME_HEARTBEAT_REQUIRED_SERVICES }} - RUNTIME_HEARTBEAT_GCS_URIS: ${{ vars.RUNTIME_HEARTBEAT_GCS_URIS || vars.EXECUTION_REPORT_GCS_URI }} - RUNTIME_HEARTBEAT_LOOKBACK_HOURS: ${{ inputs.lookback_hours || vars.RUNTIME_HEARTBEAT_LOOKBACK_HOURS || '36' }} - RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }} - RUNTIME_HEARTBEAT_ACCEPT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_ACCEPT_STATUSES }} - RUNTIME_HEARTBEAT_REJECT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_REJECT_STATUSES }} - RUNTIME_HEARTBEAT_MARKET_AWARE: ${{ vars.RUNTIME_HEARTBEAT_MARKET_AWARE || 'true' }} - RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS: ${{ github.event_name == 'workflow_dispatch' && inputs.notify_on_success && 'true' || 'false' }} - RUNTIME_HEARTBEAT_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} - RUNTIME_HEARTBEAT_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }} - RUNTIME_HEARTBEAT_PUBLICATION_GRACE_MINUTES: ${{ vars.RUNTIME_HEARTBEAT_PUBLICATION_GRACE_MINUTES || '30' }} - RUNTIME_HEARTBEAT_SCHEDULER_AWARE: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_AWARE || 'true' }} - RUNTIME_HEARTBEAT_SCHEDULER_LOCATION: ${{ vars.RUNTIME_HEARTBEAT_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION || 'us-central1' }} - RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} - RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} + uses: ./.github/workflows/execution-report-heartbeat-protected.yml + with: + lookback_hours: ${{ inputs.lookback_hours }} + notify_on_success: ${{ inputs.notify_on_success }} + fail_workflow_on_alert: ${{ inputs.fail_workflow_on_alert }} + send_daily_dry_run_digest: ${{ inputs.send_daily_dry_run_digest }} + account_facts_target: ${{ inputs.account_facts_target }} + account_facts_report_name: ${{ inputs.account_facts_report_name }} + secrets: CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} - CLOUD_SCHEDULER_MAIN_TIME: ${{ vars.CLOUD_SCHEDULER_MAIN_TIME }} - NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }} + EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }} GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} + RUNTIME_HEARTBEAT_REQUIRED_SERVICES: ${{ secrets.RUNTIME_HEARTBEAT_REQUIRED_SERVICES }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} - TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 - - - name: Install dependencies - run: | - set -euo pipefail - uv sync --frozen --no-dev - - - name: Authenticate to Google Cloud - id: gcp_auth_primary - continue-on-error: true - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} - - - name: Wait before Google Cloud authentication retry - if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} - run: sleep 10 - - - name: Retry Google Cloud authentication - if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} - 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 - - - name: Check recent execution report - run: uv run --no-sync python scripts/execution_report_heartbeat.py - - - name: Publish read-only runtime execution evidence - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@3053c2646f391a8025a259cc138e0420e6e7ba54 - with: - source-id: runtime-reports-ibkr - report-platform: interactive_brokers - report-prefix: ${{ vars.RUNTIME_HEARTBEAT_GCS_URIS || vars.EXECUTION_REPORT_GCS_URI }} - sync-url: ${{ vars.EXECUTION_EVIDENCE_SYNC_URL }} - env: - EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }} - - - name: Send configured daily dry-run digest - if: ${{ always() && (github.event_name == 'schedule' || inputs.send_daily_dry_run_digest) }} - run: uv run --no-sync python scripts/daily_dry_run_digest.py account-facts-publisher: name: Publish latest fresh IBKR account-facts report diff --git a/.github/workflows/notify-route-check-protected.yml b/.github/workflows/notify-route-check-protected.yml new file mode 100644 index 00000000..6ed891c3 --- /dev/null +++ b/.github/workflows/notify-route-check-protected.yml @@ -0,0 +1,53 @@ +name: Notify Route Check (protected execution) + +# Register the original JSON as a workflow secret before any runner step header. +# This masks the passed value, not every extracted field or reserialized variant. +on: + workflow_call: + secrets: + CLOUD_RUN_SERVICE: + required: false + CLOUD_RUN_SERVICES: + required: false + CLOUD_RUN_SERVICE_TARGETS_JSON: + required: false + +permissions: + contents: read + id-token: write + +env: + GCP_PROJECT_ID: interactivebrokersquant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + +jobs: + route-check: + name: Telegram route check (read-only) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + env: + RUNTIME_GUARD_CLOUD_RUN_SERVICES: ${{ vars.RUNTIME_GUARD_CLOUD_RUN_SERVICES }} + CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} + CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - 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 + + - name: Report Telegram route (no secret values) + run: python3 scripts/notify_route_check.py diff --git a/.github/workflows/notify-route-check.yml b/.github/workflows/notify-route-check.yml index b9bb0f71..8f71fb0c 100644 --- a/.github/workflows/notify-route-check.yml +++ b/.github/workflows/notify-route-check.yml @@ -6,38 +6,14 @@ name: Notify Route Check on: workflow_dispatch: -env: - GCP_PROJECT_ID: interactivebrokersquant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com - jobs: route-check: name: Telegram route check (read-only) - runs-on: ubuntu-latest - timeout-minutes: 10 permissions: contents: read id-token: write - env: - RUNTIME_GUARD_CLOUD_RUN_SERVICES: ${{ vars.RUNTIME_GUARD_CLOUD_RUN_SERVICES }} + uses: ./.github/workflows/notify-route-check-protected.yml + secrets: CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} - CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} - TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - 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 - - - name: Report Telegram route (no secret values) - run: python3 scripts/notify_route_check.py diff --git a/.github/workflows/runtime-guard-protected.yml b/.github/workflows/runtime-guard-protected.yml new file mode 100644 index 00000000..fff611a0 --- /dev/null +++ b/.github/workflows/runtime-guard-protected.yml @@ -0,0 +1,117 @@ +name: Runtime Guard (protected execution) + +# Register the original JSON as a workflow secret before any runner step header. +# This masks the passed value, not every extracted field or reserialized variant. +on: + workflow_call: + inputs: + lookback_minutes: + type: string + required: false + require_success: + type: string + required: false + fail_workflow_on_alert: + type: string + required: false + secrets: + CLOUD_RUN_SERVICE: + required: false + CLOUD_RUN_SERVICES: + required: false + CLOUD_RUN_SERVICE_TARGETS_JSON: + required: false + GLOBAL_TELEGRAM_CHAT_ID: + required: false + RUNTIME_GUARD_SCHEDULER_JOB_PATTERN: + required: false + TELEGRAM_TOKEN: + required: false + +permissions: + contents: read + id-token: write + +env: + GCP_PROJECT_ID: interactivebrokersquant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + +jobs: + guard: + name: Check Cloud Run runtime + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + env: + RUNTIME_GUARD_NAME: InteractiveBrokersPlatform + RUNTIME_GUARD_CLOUD_RUN_SERVICES: ${{ vars.RUNTIME_GUARD_CLOUD_RUN_SERVICES }} + RUNTIME_GUARD_LOOKBACK_MINUTES: ${{ inputs.lookback_minutes || vars.RUNTIME_GUARD_LOOKBACK_MINUTES || '180' }} + RUNTIME_GUARD_REQUIRE_SUCCESS: ${{ inputs.require_success || vars.RUNTIME_GUARD_REQUIRE_SUCCESS || 'false' }} + RUNTIME_GUARD_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_GUARD_FAIL_WORKFLOW_ON_ALERT || 'true' }} + RUNTIME_GUARD_SCHEDULER_JOB_PATTERN: ${{ secrets.RUNTIME_GUARD_SCHEDULER_JOB_PATTERN }} + CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} + CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }} + GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} + # NOTIFY-04 B5: default dry_run = log would_suppress only, never suppress. + RUNTIME_GUARD_ALERT_DEDUPE_MODE: ${{ vars.RUNTIME_GUARD_ALERT_DEDUPE_MODE || 'dry_run' }} + RUNTIME_GUARD_ALERT_DEDUPE_REMINDER_HOURS: ${{ vars.RUNTIME_GUARD_ALERT_DEDUPE_REMINDER_HOURS || '6' }} + RUNTIME_GUARD_DEDUPE_STATE_PATH: .runtime-guard-dedupe/state.json + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + + - name: Install dependencies + run: | + set -euo pipefail + uv sync --frozen --no-dev + + - name: Authenticate to Google Cloud + id: gcp_auth_primary + continue-on-error: true + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + + - name: Wait before Google Cloud authentication retry + if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} + run: sleep 10 + + - name: Retry Google Cloud authentication + if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} + 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 + + - name: Restore runtime-guard alert dedupe state + uses: actions/cache/restore@v4 + with: + path: .runtime-guard-dedupe + key: runtime-guard-dedupe-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + runtime-guard-dedupe- + + - name: Check Cloud Scheduler and Cloud Run logs + run: uv run --no-sync python scripts/cloud_run_runtime_guard.py + + - name: Save runtime-guard alert dedupe state + if: ${{ always() && hashFiles('.runtime-guard-dedupe/state.json') != '' }} + uses: actions/cache/save@v4 + with: + path: .runtime-guard-dedupe + key: runtime-guard-dedupe-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/runtime-guard.yml b/.github/workflows/runtime-guard.yml index 721efb0c..344487d4 100644 --- a/.github/workflows/runtime-guard.yml +++ b/.github/workflows/runtime-guard.yml @@ -27,11 +27,6 @@ on: schedule: - cron: "19,49 * * * *" -env: - GCP_PROJECT_ID: interactivebrokersquant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com - concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: false @@ -39,78 +34,18 @@ concurrency: jobs: guard: name: Check Cloud Run runtime - runs-on: ubuntu-latest - timeout-minutes: 15 permissions: contents: read id-token: write - env: - RUNTIME_GUARD_NAME: InteractiveBrokersPlatform - RUNTIME_GUARD_CLOUD_RUN_SERVICES: ${{ vars.RUNTIME_GUARD_CLOUD_RUN_SERVICES }} - RUNTIME_GUARD_LOOKBACK_MINUTES: ${{ inputs.lookback_minutes || vars.RUNTIME_GUARD_LOOKBACK_MINUTES || '180' }} - RUNTIME_GUARD_REQUIRE_SUCCESS: ${{ inputs.require_success || vars.RUNTIME_GUARD_REQUIRE_SUCCESS || 'false' }} - RUNTIME_GUARD_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_GUARD_FAIL_WORKFLOW_ON_ALERT || 'true' }} - RUNTIME_GUARD_SCHEDULER_JOB_PATTERN: ${{ secrets.RUNTIME_GUARD_SCHEDULER_JOB_PATTERN }} + uses: ./.github/workflows/runtime-guard-protected.yml + with: + lookback_minutes: ${{ inputs.lookback_minutes }} + require_success: ${{ inputs.require_success }} + fail_workflow_on_alert: ${{ inputs.fail_workflow_on_alert }} + secrets: CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} - CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} - NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }} GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} + RUNTIME_GUARD_SCHEDULER_JOB_PATTERN: ${{ secrets.RUNTIME_GUARD_SCHEDULER_JOB_PATTERN }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} - TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} - # NOTIFY-04 B5: default dry_run = log would_suppress only, never suppress. - RUNTIME_GUARD_ALERT_DEDUPE_MODE: ${{ vars.RUNTIME_GUARD_ALERT_DEDUPE_MODE || 'dry_run' }} - RUNTIME_GUARD_ALERT_DEDUPE_REMINDER_HOURS: ${{ vars.RUNTIME_GUARD_ALERT_DEDUPE_REMINDER_HOURS || '6' }} - RUNTIME_GUARD_DEDUPE_STATE_PATH: .runtime-guard-dedupe/state.json - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 - - - name: Install dependencies - run: | - set -euo pipefail - uv sync --frozen --no-dev - - - name: Authenticate to Google Cloud - id: gcp_auth_primary - continue-on-error: true - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} - service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} - - - name: Wait before Google Cloud authentication retry - if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} - run: sleep 10 - - - name: Retry Google Cloud authentication - if: ${{ steps.gcp_auth_primary.outcome == 'failure' }} - 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 - - - name: Restore runtime-guard alert dedupe state - uses: actions/cache/restore@v4 - with: - path: .runtime-guard-dedupe - key: runtime-guard-dedupe-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - runtime-guard-dedupe- - - - name: Check Cloud Scheduler and Cloud Run logs - run: uv run --no-sync python scripts/cloud_run_runtime_guard.py - - - name: Save runtime-guard alert dedupe state - if: ${{ always() && hashFiles('.runtime-guard-dedupe/state.json') != '' }} - uses: actions/cache/save@v4 - with: - path: .runtime-guard-dedupe - key: runtime-guard-dedupe-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/sync-cloud-run-env-protected.yml b/.github/workflows/sync-cloud-run-env-protected.yml new file mode 100644 index 00000000..3c8e8d02 --- /dev/null +++ b/.github/workflows/sync-cloud-run-env-protected.yml @@ -0,0 +1,1641 @@ +name: Deploy Cloud Run (protected execution) + +# Register the original JSON as a workflow secret before any runner step header. +# This masks the passed value, not every extracted field or reserialized variant. +on: + workflow_call: + inputs: + target: + type: string + required: false + configured_service: + type: string + required: false + source_commit: + type: string + required: false + cloud_run_region: + type: string + required: false + cloud_run_service: + type: string + required: false + account_group: + type: string + required: false + account_group_config_secret_name: + type: string + required: false + deploy_image: + type: boolean + required: false + sync_env: + type: boolean + required: false + approve_traffic_shift: + type: boolean + required: false + approve_scheduler_sync: + type: boolean + required: false + secrets: + CLOUD_RUN_SERVICE: + required: false + CLOUD_RUN_SERVICES: + required: false + CLOUD_RUN_SERVICE_TARGETS_JSON: + required: false + GLOBAL_TELEGRAM_CHAT_ID: + required: false + RUNTIME_TARGET_JSON: + required: false + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: + required: false + STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: + required: false + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: + required: false + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: + required: false + TELEGRAM_TOKEN: + required: false + TG_TOKEN: + required: false + +permissions: + actions: read + contents: read + id-token: write + +env: + GCP_PROJECT_ID: interactivebrokersquant + GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com + GCP_RUNTIME_SERVICE_ACCOUNT: ibkr-platform-runtime@interactivebrokersquant.iam.gserviceaccount.com + GCP_SCHEDULER_SERVICE_ACCOUNT: ibkr-platform-scheduler@interactivebrokersquant.iam.gserviceaccount.com + GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy + +jobs: + sync-cloud-run-env: + name: Deploy / Sync Cloud Run + # Production deployment and environment sync require an explicit operator dispatch. + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + id-token: write + env: + ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }} + ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} + ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} + QSL_ENABLE_CLOUD_RUN_AUTOMATION: ${{ vars.QSL_ENABLE_CLOUD_RUN_AUTOMATION }} + CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }} + WORKFLOW_TARGET: ${{ inputs.target || 'configured' }} + INPUT_CONFIGURED_SERVICE: ${{ inputs.configured_service }} + INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }} + SOURCE_COMMIT: ${{ inputs.source_commit || github.sha }} + INPUT_CLOUD_RUN_REGION: ${{ inputs.cloud_run_region }} + INPUT_CLOUD_RUN_SERVICE: ${{ inputs.cloud_run_service }} + INPUT_ACCOUNT_GROUP: ${{ inputs.account_group }} + INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ inputs.account_group_config_secret_name }} + INPUT_DEPLOY_IMAGE: ${{ inputs.deploy_image }} + INPUT_SYNC_ENV: ${{ inputs.sync_env }} + INPUT_APPROVE_TRAFFIC_SHIFT: ${{ inputs.approve_traffic_shift }} + INPUT_APPROVE_SCHEDULER_SYNC: ${{ inputs.approve_scheduler_sync }} + GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} + CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} + CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} + # This is the validated non-secret service inventory maintained by the + # control plane; legacy Secret support is only a migration fallback. + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }} + CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} + 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 }} + TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} + # This is validated deployment intent, not credential material. Prefer the + # control-plane variable while retaining a legacy-secret fallback during migration. + RUNTIME_TARGET_JSON: ${{ secrets.RUNTIME_TARGET_JSON }} + ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }} + IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }} + IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }} + IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }} + IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE }} + IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }} + IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS }} + IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }} + IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }} + IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} + IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} + IBKR_EXECUTION_DEDUP_ENABLED: ${{ vars.IBKR_EXECUTION_DEDUP_ENABLED }} + IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }} + IBKR_FORCE_RUN: ${{ vars.IBKR_FORCE_RUN }} + IBKR_MARKET: ${{ vars.IBKR_MARKET }} + IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} + IBKR_MARKET_CURRENCY: ${{ vars.IBKR_MARKET_CURRENCY }} + IBKR_MARKET_DATA_SYMBOL_SUFFIX: ${{ vars.IBKR_MARKET_DATA_SYMBOL_SUFFIX }} + IBKR_MARKET_EXCHANGE: ${{ vars.IBKR_MARKET_EXCHANGE }} + IBKR_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }} + IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }} + IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }} + IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} + INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} + INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }} + INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} + IBKR_CASH_ONLY_EXECUTION: ${{ vars.IBKR_CASH_ONLY_EXECUTION }} + CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }} + DCA_MODE: ${{ vars.DCA_MODE }} + DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }} + IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }} + IBIT_ZSCORE_EXIT_MODE: ${{ vars.IBIT_ZSCORE_EXIT_MODE }} + IBIT_ZSCORE_EXIT_PARKING_SYMBOL: ${{ vars.IBIT_ZSCORE_EXIT_PARKING_SYMBOL }} + IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }} + IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE }} + IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }} + IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI }} + IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} + IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} + IBKR_MARKET_SIGNAL_CACHE_DIR: ${{ vars.IBKR_MARKET_SIGNAL_CACHE_DIR }} + IBKR_MARKET_SIGNAL_REQUIRED: ${{ vars.IBKR_MARKET_SIGNAL_REQUIRED }} + IBKR_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.IBKR_MARKET_SIGNAL_FALLBACK_MODE }} + IBKR_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.IBKR_MARKET_SIGNAL_MAX_STALE_DAYS }} + STRATEGY_PLUGIN_ALERT_CHANNELS: ${{ vars.STRATEGY_PLUGIN_ALERT_CHANNELS }} + STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS }} + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL }} + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST }} + STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT }} + STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY }} + STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS }} + STRATEGY_PLUGIN_ALERT_SMS_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_PROVIDER }} + STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID }} + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_SMS_SENDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_SENDER }} + STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID }} + STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL }} + STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS }} + STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS }} + STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER }} + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL }} + STRATEGY_PLUGIN_ALERT_PUSH_DEVICE: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_DEVICE }} + STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY }} + STRATEGY_PLUGIN_ALERT_PUSH_TAGS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_TAGS }} + STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS }} + # Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs. + EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} + # Empty by default. When explicitly set, the workflow downloads one + # immutable, private recovery ledger before deriving its sync plan. + IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI: ${{ vars.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }} + GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} + NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD }} + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} + STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN }} + STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.TG_TOKEN }} + steps: + - name: Check whether Cloud Run automation is enabled + id: config + run: | + set -euo pipefail + + write_github_output() { + printf '%s\n' "$@" >> "$GITHUB_OUTPUT" + } + + deploy_enabled=false + env_sync_enabled=false + traffic_shift_enabled=false + scheduler_sync_enabled=false + + # QSL_ENABLE_CLOUD_RUN_AUTOMATION overrides ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION + ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION="${QSL_ENABLE_CLOUD_RUN_AUTOMATION:-$ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION}" + if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION:-}" != "true" ]; then + write_github_output \ + "deploy_enabled=false" \ + "env_sync_enabled=false" \ + "traffic_shift_enabled=false" \ + "scheduler_sync_enabled=false" \ + "enabled=false" + echo "Skipping Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2 + exit 0 + fi + + if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${WORKFLOW_TARGET:-configured}" = "hk-verify" ]; then + if [ "${INPUT_DEPLOY_IMAGE:-true}" = "true" ]; then + deploy_enabled=true + fi + if [ "${INPUT_SYNC_ENV:-true}" = "true" ]; then + env_sync_enabled=true + fi + else + if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ] \ + && { [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ] || [ "${INPUT_DEPLOY_IMAGE:-true}" = "true" ]; }; then + deploy_enabled=true + fi + + if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ] \ + && { [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ] || [ "${INPUT_SYNC_ENV:-true}" = "true" ]; }; then + env_sync_enabled=true + fi + fi + + if [ "${INPUT_APPROVE_TRAFFIC_SHIFT:-false}" = "true" ]; then + traffic_shift_enabled=true + fi + if [ "${INPUT_APPROVE_SCHEDULER_SYNC:-false}" = "true" ]; then + scheduler_sync_enabled=true + fi + + if [ "${WORKFLOW_TARGET:-configured}" = "configured" ] && [ -z "${INPUT_CONFIGURED_SERVICE:-}" ]; then + if [ "${deploy_enabled}" = "true" ] || [ "${env_sync_enabled}" = "true" ] || [ "${traffic_shift_enabled}" = "true" ] || [ "${scheduler_sync_enabled}" = "true" ]; then + echo "configured target requires exact configured_service; refusing multi-service default mutations." >&2 + exit 1 + fi + fi + + if { [ "${deploy_enabled}" = "true" ] || [ "${env_sync_enabled}" = "true" ] || [ "${traffic_shift_enabled}" = "true" ] || [ "${scheduler_sync_enabled}" = "true" ]; } \ + && [ "${GITHUB_REF:-}" != "refs/heads/main" ]; then + echo "Cloud Run mutations require the trusted main workflow ref." >&2 + exit 1 + fi + + if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${deploy_enabled}" = "true" ] \ + && [ -n "${INPUT_SOURCE_COMMIT:-}" ] \ + && [[ ! "${INPUT_SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Explicit source_commit must be a full 40-character SHA." >&2 + exit 1 + fi + + write_github_output \ + "deploy_enabled=${deploy_enabled}" \ + "env_sync_enabled=${env_sync_enabled}" \ + "traffic_shift_enabled=${traffic_shift_enabled}" \ + "scheduler_sync_enabled=${scheduler_sync_enabled}" + + if [ "${deploy_enabled}" != "true" ] && [ "${env_sync_enabled}" != "true" ] && [ "${traffic_shift_enabled}" != "true" ] && [ "${scheduler_sync_enabled}" != "true" ]; then + write_github_output "enabled=false" + echo "Skipping Cloud Run automation because ENABLE_GITHUB_CLOUD_RUN_DEPLOY and ENABLE_GITHUB_ENV_SYNC are not true." >&2 + exit 0 + fi + + write_github_output "enabled=true" + + - name: Checkout repository + if: steps.config.outputs.enabled == 'true' + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + + - name: Checkout selected image source + if: steps.config.outputs.deploy_enabled == 'true' + uses: actions/checkout@v6 + with: + repository: ${{ github.repository }} + ref: ${{ env.SOURCE_COMMIT }} + path: source + persist-credentials: false + + - name: Verify selected image source + if: steps.config.outputs.deploy_enabled == 'true' + run: | + set -euo pipefail + if [[ ! "${SOURCE_COMMIT:-}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Selected source must be a full commit SHA." >&2 + exit 1 + fi + checked_out_sha="$(git -C source rev-parse HEAD)" + if [ "${checked_out_sha}" != "${SOURCE_COMMIT}" ]; then + echo "Checked-out image source does not match source_commit." >&2 + exit 1 + fi + + - name: Apply HK verify-only dispatch defaults + if: steps.config.outputs.enabled == 'true' && github.event_name == 'workflow_dispatch' && inputs.target == 'hk-verify' + run: | + set -euo pipefail + + service="${INPUT_CLOUD_RUN_SERVICE:-interactive-brokers-hk-verify-service}" + account_group="${INPUT_ACCOUNT_GROUP:-hk-verify}" + region="${INPUT_CLOUD_RUN_REGION:-${CLOUD_RUN_REGION:-}}" + runtime_target="$(SERVICE="${service}" ACCOUNT_GROUP_VALUE="${account_group}" python3 - <<'PY' + import json + import os + + print( + json.dumps( + { + "platform_id": "ibkr", + "strategy_profile": "hk_global_etf_tactical_rotation", + "deployment_selector": os.environ["ACCOUNT_GROUP_VALUE"], + "account_scope": os.environ["ACCOUNT_GROUP_VALUE"], + "execution_mode": "paper", + "dry_run_only": True, + "service_name": os.environ["SERVICE"], + }, + separators=(",", ":"), + ) + ) + PY + )" + + { + echo "CLOUD_RUN_SERVICE=${service}" + echo "CLOUD_RUN_SERVICES=" + echo "CLOUD_RUN_SERVICE_TARGETS_JSON=" + echo "ACCOUNT_GROUP=${account_group}" + echo "RUNTIME_TARGET_JSON=${runtime_target}" + echo "IBKR_DRY_RUN_ONLY=true" + echo "IBKR_MARKET=HK" + echo "IBKR_MARKET_CALENDAR=XHKG" + echo "IBKR_MARKET_CURRENCY=HKD" + echo "IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK" + echo "IBKR_MARKET_EXCHANGE=SEHK" + echo "IBKR_MARKET_TIMEZONE=Asia/Hong_Kong" + } >> "$GITHUB_ENV" + + if [ -n "${region}" ]; then + echo "CLOUD_RUN_REGION=${region}" >> "$GITHUB_ENV" + fi + if [ -n "${INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME:-}" ]; then + echo "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=${INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME}" >> "$GITHUB_ENV" + fi + if [ "${INPUT_DEPLOY_IMAGE:-true}" != "true" ]; then + echo "CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT=false" >> "$GITHUB_ENV" + fi + + - name: Set up Python for runtime target admission + if: steps.config.outputs.enabled == 'true' + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Authenticate to Google Cloud + id: auth + if: steps.config.outputs.enabled == 'true' + 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 + if: steps.config.outputs.enabled == 'true' + uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ env.GCP_PROJECT_ID }} + version: ">= 416.0.0" + + - name: Fetch opt-in immutable recovery state ledger + if: steps.config.outputs.enabled == 'true' && env.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI != '' + env: + RECOVERY_STATE_LEDGER_URI: ${{ env.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }} + run: | + set -euo pipefail + case "${RECOVERY_STATE_LEDGER_URI}" in + gs://qsl-reconciliation-recovery-private-ibkr/reconciliation-recovery/ibkr/state/*.json) ;; + *) + echo "Recovery state ledger URI must use the dedicated private immutable prefix." >&2 + exit 1 + ;; + esac + recovery_state_ledger_path="${RUNNER_TEMP}/ibkr-reconciliation-recovery-state-ledger.json" + gcloud storage cp --quiet "${RECOVERY_STATE_LEDGER_URI}" "${recovery_state_ledger_path}" >/dev/null + chmod 600 "${recovery_state_ledger_path}" + echo "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PATH=${recovery_state_ledger_path}" >> "$GITHUB_ENV" + + - name: Install strategy status dependencies + if: steps.config.outputs.enabled == 'true' + run: | + set -euo pipefail + python -m pip install --upgrade pip uv + uv sync --frozen --no-dev + - name: Resolve admissible Cloud Run targets + id: strategy_requirements + if: steps.config.outputs.enabled == 'true' + run: | + set -euo pipefail + sync_plan_json="$(uv run --no-sync python scripts/build_cloud_run_env_sync_plan.py --json)" + export SYNC_PLAN_JSON="${sync_plan_json}" + python - <<'PY' + import json + import os + import re + from pathlib import Path + + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + selected = os.environ.get("INPUT_CONFIGURED_SERVICE", "") + if os.environ.get("WORKFLOW_TARGET", "configured") == "configured": + if not selected: + raise SystemExit("configured target requires exact configured_service matching one admitted inventory service_name") + if not re.fullmatch(r"[a-z][a-z0-9-]{0,62}", selected) or plan.get("mode") != "per_service": + raise SystemExit("configured_service requires an exact private inventory service_name") + matches = [target for target in plan["targets"] if target.get("service_name") == selected] + if len(matches) != 1: + raise SystemExit("configured_service must match exactly one admitted inventory target") + plan["targets"] = matches + # Reconciliation also reads inventory and legacy env; scope every source. + inventory = json.dumps({"targets": [{"service_name": selected}]}) + with Path(os.environ["GITHUB_ENV"]).open("a") as handle: + handle.write(f"CLOUD_RUN_SERVICE_TARGETS_JSON={inventory}\n") + handle.write(f"CLOUD_RUN_SERVICE={selected}\nCLOUD_RUN_SERVICES={selected}\n") + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as handle: + handle.write("sync_plan_json<<__SYNC_PLAN_JSON__\n") + handle.write(json.dumps(plan) + "\n__SYNC_PLAN_JSON__\n") + PY + + - name: Validate env sync inputs + if: steps.config.outputs.env_sync_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + + required_vars=( + CLOUD_RUN_REGION + ) + + missing_vars=() + for var_name in "${required_vars[@]}"; do + if [ -z "${!var_name:-}" ]; then + missing_vars+=("${var_name}") + fi + done + + if [ -z "${TELEGRAM_TOKEN_SECRET_NAME:-}" ] && [ -z "${TELEGRAM_TOKEN:-}" ]; then + missing_vars+=("TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN") + fi + + python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + targets = plan.get("targets") or [] + if not targets: + raise SystemExit("Cloud Run env sync did not resolve any targets") + for target in targets: + service_name = str(target.get("service_name") or "").strip() + if not service_name: + raise SystemExit("Cloud Run sync target is missing service_name") + if not isinstance(target.get("env"), dict): + raise SystemExit(f"Cloud Run sync target {service_name} is missing env") + PY + + if [ "${#missing_vars[@]}" -gt 0 ]; then + echo "Cloud Run env sync is enabled, but these values are missing:" >&2 + printf ' - %s\n' "${missing_vars[@]}" >&2 + exit 1 + fi + + - name: Validate deploy inputs + if: steps.config.outputs.deploy_enabled == 'true' + run: | + set -euo pipefail + + if [ -z "${CLOUD_RUN_REGION:-}" ]; then + echo "Cloud Run deploy is enabled, but CLOUD_RUN_REGION is missing." >&2 + exit 1 + fi + + mapfile -t target_services < <(python - <<'PY' + import json + import os + + raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip() + if raw_targets: + payload = json.loads(raw_targets) + entries = payload.get("targets") if isinstance(payload, dict) else payload + if not isinstance(entries, list): + raise SystemExit("CLOUD_RUN_SERVICE_TARGETS_JSON must be a JSON array or an object with targets") + for entry in entries: + if not isinstance(entry, dict): + raise SystemExit("Each Cloud Run deploy target must be an object") + service = ( + entry.get("service") + or entry.get("service_name") + or entry.get("cloud_run_service") + ) + runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json") + if not service and isinstance(runtime_target, str): + runtime_target = json.loads(runtime_target) + if not service and isinstance(runtime_target, dict): + service = runtime_target.get("service_name") + service = str(service or "").strip() + if service: + print(service) + raise SystemExit(0) + + raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip() + for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","): + service = chunk.strip() + if service: + print(service) + PY + ) + + if [ "${#target_services[@]}" -eq 0 ]; then + echo "Cloud Run deploy is enabled, but no services were found in CLOUD_RUN_SERVICE_TARGETS_JSON, CLOUD_RUN_SERVICES, or CLOUD_RUN_SERVICE." >&2 + exit 1 + fi + + - name: Require configured scheduler migration before HK verify + if: steps.config.outputs.env_sync_enabled == 'true' && env.WORKFLOW_TARGET == 'hk-verify' + run: | + set -euo pipefail + scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" + if gcloud scheduler jobs describe "interactive-brokers-monitor-dispatcher-scheduler" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" >/dev/null 2>&1; then + echo "HK verify scheduler migration requires the configured migration first." >&2 + exit 1 + fi + + - name: Capture no-traffic deployment baseline + if: steps.config.outputs.deploy_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + DEPLOY_READBACK_DIR: ${{ runner.temp }}/cloud-run-no-traffic-baseline + run: | + set -euo pipefail + mkdir -p "${DEPLOY_READBACK_DIR}" + mapfile -t target_services < <(python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + for target in plan["targets"]: + print(target["service_name"]) + PY + ) + if [ "${#target_services[@]}" -eq 0 ]; then + echo "Cloud Run deploy did not resolve any targets." >&2 + exit 1 + fi + for cloud_run_service in "${target_services[@]}"; do + python3 scripts/verify_cloud_run_no_traffic_deploy.py capture \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --service="${cloud_run_service}" \ + --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ + --output="${DEPLOY_READBACK_DIR}/${cloud_run_service}.json" + done + + - name: Verify deployed runtime target admission before traffic shift + if: steps.config.outputs.deploy_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + uv run --no-sync python scripts/verify_deployed_runtime_target_admission.py \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" + + - name: Build, push, and deploy Cloud Run image + id: deploy + if: steps.config.outputs.deploy_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + + mapfile -t target_services < <(python - <<'PY' + import json + import os + + raw_plan = os.environ.get("SYNC_PLAN_JSON", "").strip() + if raw_plan: + plan = json.loads(raw_plan) + for target in plan["targets"]: + service = str(target.get("service_name") or "").strip() + if service: + print(service) + raise SystemExit(0) + + raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip() + if raw_targets: + payload = json.loads(raw_targets) + entries = payload.get("targets") if isinstance(payload, dict) else payload + for entry in entries: + service = ( + entry.get("service") + or entry.get("service_name") + or entry.get("cloud_run_service") + ) + runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json") + if not service and isinstance(runtime_target, str): + runtime_target = json.loads(runtime_target) + if not service and isinstance(runtime_target, dict): + service = runtime_target.get("service_name") + service = str(service or "").strip() + if service: + print(service) + raise SystemExit(0) + + raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip() + for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","): + service = chunk.strip() + if service: + print(service) + PY + ) + if [ "${#target_services[@]}" -eq 0 ]; then + echo "Cloud Run deploy did not resolve any targets." >&2 + exit 1 + fi + + artifact_registry_hostname="${GCP_ARTIFACT_REGISTRY_HOSTNAME:-${CLOUD_RUN_REGION}-docker.pkg.dev}" + image_repo="${artifact_registry_hostname}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/interactivebrokersplatform/cloud-run" + image="${image_repo}:${SOURCE_COMMIT}-${GITHUB_RUN_ID}" + + gcloud auth configure-docker "${artifact_registry_hostname}" --quiet + docker build --pull -t "${image}" source + # RS-08 guard: block the deploy if the image's installed UES revision differs from + # strategy_release / runtime-risk binding (live would reject every SOXL rebalance). + python3 scripts/verify_image_ues_release_binding.py --image "${image}" --lockfile source/uv.lock + docker push "${image}" + image_digest="$(gcloud artifacts docker images describe "${image}" --project="${GCP_PROJECT_ID}" --format='value(image_summary.digest)')" + if [[ ! "${image_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Artifact Registry did not return a valid image digest." >&2 + exit 1 + fi + immutable_image="${image_repo}@${image_digest}" + echo "image_digest=${image_digest}" >> "${GITHUB_OUTPUT}" + + for cloud_run_service in "${target_services[@]}"; do + gcloud run deploy "${cloud_run_service}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --platform=managed \ + --image="${immutable_image}" \ + --service-account="${GCP_RUNTIME_SERVICE_ACCOUNT}" \ + --no-allow-unauthenticated \ + --ingress=internal \ + --max-instances=1 \ + --concurrency=1 \ + --memory=512Mi \ + --cpu=1 \ + --timeout=300s \ + --network=default \ + --subnet=default \ + --vpc-egress=private-ranges-only \ + --labels="managed-by=github-actions,commit-sha=${SOURCE_COMMIT},github-run-id=${GITHUB_RUN_ID}" \ + --no-traffic \ + --quiet + done + + - name: Verify no-traffic deployment readback + if: steps.config.outputs.deploy_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + DEPLOY_READBACK_DIR: ${{ runner.temp }}/cloud-run-no-traffic-baseline + EXPECTED_IMAGE_DIGEST: ${{ steps.deploy.outputs.image_digest }} + run: | + set -euo pipefail + mapfile -t target_services < <(python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + for target in plan["targets"]: + print(target["service_name"]) + PY + ) + for cloud_run_service in "${target_services[@]}"; do + python3 scripts/verify_cloud_run_no_traffic_deploy.py verify \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --service="${cloud_run_service}" \ + --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ + --before="${DEPLOY_READBACK_DIR}/${cloud_run_service}.json" \ + --expected-sha="${SOURCE_COMMIT}" \ + --expected-image-digest="${EXPECTED_IMAGE_DIGEST}" + done + + - name: Wait for Cloud Run deployment of current commit + if: steps.config.outputs.deploy_enabled == 'true' && steps.config.outputs.env_sync_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + + mapfile -t target_services < <(python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + for target in plan["targets"]: + print(target["service_name"]) + PY + ) + if [ "${#target_services[@]}" -eq 0 ]; then + echo "Cloud Run env sync did not resolve any targets." >&2 + exit 1 + fi + + case "${CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT:-true}" in + false|False|FALSE|0|no|No|NO|off|Off|OFF) + echo "Skipping Cloud Run commit wait because CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT is disabled." + exit 0 + ;; + esac + + target_sha="${SOURCE_COMMIT}" + + for cloud_run_service in "${target_services[@]}"; do + deadline=$((SECONDS + 1800)) + + while true; do + deployed_sha="$(gcloud run services describe "${cloud_run_service}" --region "${CLOUD_RUN_REGION}" --format='value(spec.template.metadata.labels.commit-sha)' 2>/dev/null || true)" + if [ -n "${deployed_sha}" ] && [ "${deployed_sha}" = "${target_sha}" ]; then + echo "Cloud Run service ${cloud_run_service} is on commit ${deployed_sha}." + break + fi + + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo "Timed out waiting for Cloud Run service ${cloud_run_service} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 + exit 1 + fi + + echo "Waiting for Cloud Run service ${cloud_run_service} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 + sleep 10 + done + done + + - name: Sync Cloud Run environment + if: steps.config.outputs.env_sync_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + + mapfile -t target_services < <(python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + for target in plan["targets"]: + print(target["service_name"]) + PY + ) + if [ "${#target_services[@]}" -eq 0 ]; then + echo "Cloud Run env sync did not resolve any targets." >&2 + exit 1 + fi + + emit_target_env_pairs() { + local target_index="$1" + SYNC_TARGET_INDEX="${target_index}" python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] + for key, value in sorted(target["env"].items()): + print(f"{key}={value}") + PY + } + + emit_target_remove_env_vars() { + local target_index="$1" + SYNC_TARGET_INDEX="${target_index}" python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] + for key in sorted(target.get("remove_env_vars") or []): + print(key) + PY + } + + emit_target_label_pairs() { + local target_index="$1" + SYNC_TARGET_INDEX="${target_index}" python - <<'PY' + import json + import os + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] + strategy_profile = str(target.get("strategy_profile") or "").strip() + if strategy_profile: + print(f"strategy={strategy_profile}") + PY + } + + join_by_delimiter() { + local delimiter="$1" + shift + local output="" + local item + for item in "$@"; do + if [ -z "${output}" ]; then + output="${item}" + else + output="${output}${delimiter}${item}" + fi + done + printf '%s' "${output}" + } + + shared_env_pairs=() + shared_secret_pairs=() + + shared_remove_env_vars=( + "IB_GATEWAY_HOST" + "IB_GATEWAY_PORT" + "TELEGRAM_CHAT_ID" + "IB_CLIENT_ID" + "IB_GATEWAY_INSTANCE_NAME" + "IB_GATEWAY_MODE" + "CRISIS_ALERT_GOOGLE_VOICE_TO" + "CRISIS_ALERT_GOOGLE_VOICE_GATEWAY" + "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER" + "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" + "CRISIS_ALERT_GOOGLE_VOICE_RECIPIENTS" + "CRISIS_ALERT_GOOGLE_VOICE_SENDER_EMAIL" + "CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD" + "CRISIS_ALERT_GOOGLE_VOICE_SMTP_HOST" + "CRISIS_ALERT_GOOGLE_VOICE_SMTP_PORT" + "CRISIS_ALERT_GOOGLE_VOICE_SMTP_SECURITY" + "CRISIS_ALERT_SMTP_FROM" + "CRISIS_ALERT_SMTP_HOST" + "CRISIS_ALERT_SMTP_PORT" + "CRISIS_ALERT_SMTP_USERNAME" + "CRISIS_ALERT_SMTP_PASSWORD" + "CRISIS_ALERT_SMTP_STARTTLS" + "CRISIS_ALERT_SMTP_SSL" + "CRISIS_ALERT_CHANNELS" + "CRISIS_ALERT_EMAIL_RECIPIENTS" + "CRISIS_ALERT_EMAIL_SENDER_EMAIL" + "CRISIS_ALERT_EMAIL_SENDER_PASSWORD" + "CRISIS_ALERT_EMAIL_SMTP_HOST" + "CRISIS_ALERT_EMAIL_SMTP_PORT" + "CRISIS_ALERT_EMAIL_SMTP_SECURITY" + "CRISIS_ALERT_SMS_RECIPIENTS" + "CRISIS_ALERT_SMS_PROVIDER" + "CRISIS_ALERT_SMS_ACCOUNT_ID" + "CRISIS_ALERT_SMS_AUTH_TOKEN" + "CRISIS_ALERT_SMS_SENDER" + "CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID" + "CRISIS_ALERT_SMS_API_BASE_URL" + "CRISIS_ALERT_SMS_BODY_MAX_CHARS" + "CRISIS_ALERT_PUSH_RECIPIENTS" + "CRISIS_ALERT_PUSH_PROVIDER" + "CRISIS_ALERT_PUSH_APP_TOKEN" + "CRISIS_ALERT_PUSH_ACCESS_TOKEN" + "CRISIS_ALERT_PUSH_API_BASE_URL" + "CRISIS_ALERT_PUSH_DEVICE" + "CRISIS_ALERT_PUSH_PRIORITY" + "CRISIS_ALERT_PUSH_TAGS" + "CRISIS_ALERT_PUSH_BODY_MAX_CHARS" + "CRISIS_ALERT_TELEGRAM_CHAT_IDS" + "CRISIS_ALERT_TELEGRAM_BOT_TOKEN" + "CRISIS_ALERT_TELEGRAM_API_BASE_URL" + "CRISIS_ALERT_TELEGRAM_PARSE_MODE" + "CRISIS_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW" + "CRISIS_ALERT_TELEGRAM_BODY_MAX_CHARS" + ) + shared_remove_secret_vars=( + "CRISIS_ALERT_SMTP_PASSWORD" + "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" + "CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD" + "CRISIS_ALERT_EMAIL_SENDER_PASSWORD" + "CRISIS_ALERT_SMS_AUTH_TOKEN" + "CRISIS_ALERT_PUSH_APP_TOKEN" + "CRISIS_ALERT_PUSH_ACCESS_TOKEN" + "CRISIS_ALERT_TELEGRAM_BOT_TOKEN" + ) + + if [ -n "${TELEGRAM_TOKEN_SECRET_NAME:-}" ]; then + shared_secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest") + else + shared_env_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN}") + shared_remove_secret_vars+=("TELEGRAM_TOKEN") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME:-}" ]; then + shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest") + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") + elif [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD}") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME:-}" ]; then + shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME}:latest") + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") + elif [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN}") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME:-}" ]; then + shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest") + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") + elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN}") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME:-}" ]; then + shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest") + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") + elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN}") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME:-}" ]; then + shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME}:latest") + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") + elif [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN}") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") + shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_CHANNELS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_CHANNELS=${STRATEGY_PLUGIN_ALERT_CHANNELS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_CHANNELS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER=${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID=${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_SENDER:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER=${STRATEGY_PLUGIN_ALERT_SMS_SENDER}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID=${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER=${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE=${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY=${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_TAGS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS=${STRATEGY_PLUGIN_ALERT_PUSH_TAGS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE=${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW=${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW") + fi + + if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS:-}" ]; then + shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS}") + else + shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS") + fi + + for target_index in "${!target_services[@]}"; do + cloud_run_service="${target_services[${target_index}]}" + mapfile -t target_env_pairs < <(emit_target_env_pairs "${target_index}") + mapfile -t target_remove_env_vars < <(emit_target_remove_env_vars "${target_index}") + mapfile -t target_label_pairs < <(emit_target_label_pairs "${target_index}") + env_pairs=("${target_env_pairs[@]}" "${shared_env_pairs[@]}") + remove_env_vars=("${target_remove_env_vars[@]}" "${shared_remove_env_vars[@]}") + secret_pairs=("${shared_secret_pairs[@]}") + remove_secret_vars=("${shared_remove_secret_vars[@]}") + + gcloud_args=( + run services update "${cloud_run_service}" + --region "${CLOUD_RUN_REGION}" + --concurrency 1 + --max-instances 1 + --no-traffic + --remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")" + --update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")" + ) + + if [ "${#remove_secret_vars[@]}" -gt 0 ]; then + gcloud_args+=(--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")") + fi + + if [ "${#secret_pairs[@]}" -gt 0 ]; then + gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")") + fi + + if [ "${#target_label_pairs[@]}" -gt 0 ]; then + gcloud_args+=(--update-labels "$(IFS=,; echo "${target_label_pairs[*]}")") + fi + + gcloud "${gcloud_args[@]}" + done + + - name: Reconcile Cloud Run traffic + if: steps.config.outputs.traffic_shift_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + python3 scripts/reconcile_cloud_runtime.py \ + --platform=ibkr \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --ensure-latest-traffic + + - name: Verify strategy plugin mounts + if: steps.config.outputs.env_sync_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + STRATEGY_PLUGIN_MOUNT_ENV_NAMES: IBKR_STRATEGY_PLUGIN_MOUNTS_JSON + run: | + set -euo pipefail + python3 scripts/verify_cloud_run_strategy_plugin_mounts.py + + - name: Sync Cloud Scheduler schedule + if: steps.config.outputs.scheduler_sync_enabled == 'true' + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + + scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" + if [ -z "${scheduler_location}" ]; then + echo "Cloud Scheduler schedule sync requires CLOUD_RUN_REGION or CLOUD_SCHEDULER_LOCATION." >&2 + exit 1 + fi + if [ -z "${GCP_SCHEDULER_SERVICE_ACCOUNT:-}" ]; then + echo "Cloud Scheduler schedule sync requires GCP_SCHEDULER_SERVICE_ACCOUNT." >&2 + exit 1 + fi + + mapfile -t scheduler_updates < <(python - <<'PY' + import json + import os + + def configured_time(name: str, default: str) -> str: + return os.environ.get(name, "").strip() or default + + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + for target in plan["targets"]: + service_name = str(target["service_name"]).strip() + env = target.get("env") or {} + scheduler = target.get("scheduler") or {} + timezone = str(scheduler.get("timezone") or env.get("IBKR_MARKET_TIMEZONE") or "").strip() + market = str(env.get("IBKR_MARKET") or "").strip().upper() + if not timezone: + timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York" + print( + "\t".join( + [ + service_name, + timezone, + str(scheduler.get("main_time") or configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15 * * 1-5")), + str(scheduler.get("probe_time") or configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15 * * 1-5")), + str(scheduler.get("precheck_time") or configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9 * * 1-5")), + str(bool(target.get("standard_execution_enabled", False))).lower(), + str(bool(target.get("drill_precheck_enabled", False))).lower(), + str(scheduler.get("attempt_deadline") or ""), + ] + ) + ) + PY + ) + + for update in "${scheduler_updates[@]}"; do + IFS=$'\t' read -r cloud_run_service market_timezone main_time warmup_time precheck_time standard_execution_enabled drill_precheck_enabled main_attempt_deadline <<< "${update}" + if [ -z "${cloud_run_service}" ] || [ -z "${market_timezone}" ]; then + continue + fi + + service_url="$(gcloud run services describe "${cloud_run_service}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --format='value(status.url)' 2>/dev/null || true)" + if [ -z "${service_url}" ]; then + echo "Unable to resolve Cloud Run service URL for ${cloud_run_service}; cannot sync scheduler URI." >&2 + exit 1 + fi + gcloud run services add-iam-policy-binding "${cloud_run_service}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --member="serviceAccount:${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --role="roles/run.invoker" \ + --quiet + gcloud run services add-iam-policy-binding "${cloud_run_service}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --member="serviceAccount:${GCP_RUNTIME_SERVICE_ACCOUNT}" \ + --role="roles/run.invoker" \ + --quiet + + scheduler_job_candidates=() + if [[ "${cloud_run_service}" == *-service ]]; then + scheduler_job_candidates+=("${cloud_run_service%-service}-scheduler") + fi + scheduler_job_candidates+=("${cloud_run_service}-scheduler") + + job_name="" + current_schedule="" + job_exists="false" + for candidate_job in "${scheduler_job_candidates[@]}"; do + current_schedule="$(gcloud scheduler jobs describe "${candidate_job}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --format='value(schedule)' 2>/dev/null || true)" + if [ -n "${current_schedule}" ]; then + job_name="${candidate_job}" + job_exists="true" + break + fi + done + + if [ -z "${current_schedule}" ]; then + job_name="${scheduler_job_candidates[0]}" + current_schedule="0 0 * * *" + fi + + desired_schedule="$(CURRENT_SCHEDULE="${current_schedule}" SCHEDULE_TIME="${main_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 + )" + + desired_warmup_schedule="$(CURRENT_SCHEDULE="${desired_schedule}" SCHEDULE_TIME="${warmup_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 + )" + + 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" + main_attempt_deadline_args=() + if [ -n "${main_attempt_deadline}" ]; then + main_attempt_deadline_args+=(--attempt-deadline="${main_attempt_deadline}") + fi + if [ "${job_exists}" = "true" ]; then + echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule}, timezone to ${market_timezone}, and URI to ${scheduler_uri}." + gcloud scheduler jobs update http "${job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --uri="${scheduler_uri}" \ + --schedule="${desired_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=POST \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + "${main_attempt_deadline_args[@]}" \ + --quiet + else + echo "Creating Cloud Scheduler job ${job_name} schedule ${desired_schedule}, timezone ${market_timezone}, and URI ${scheduler_uri}." + gcloud scheduler jobs create http "${job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --uri="${scheduler_uri}" \ + --schedule="${desired_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=POST \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + "${main_attempt_deadline_args[@]}" \ + --quiet + fi + + warmup_job_name="${cloud_run_service%-service}-warmup-scheduler" + warmup_uri="${service_url}/health" + if gcloud scheduler jobs describe "${warmup_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" >/dev/null 2>&1; then + echo "Updating Cloud Scheduler warmup ${warmup_job_name} to ${desired_warmup_schedule}." + gcloud scheduler jobs update http "${warmup_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --uri="${warmup_uri}" \ + --schedule="${desired_warmup_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=GET \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + --attempt-deadline=60s \ + --max-retry-attempts=2 \ + --min-backoff=15s \ + --max-backoff=60s \ + --max-doublings=2 \ + --quiet + else + echo "Creating Cloud Scheduler warmup ${warmup_job_name} at ${desired_warmup_schedule}." + gcloud scheduler jobs create http "${warmup_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --uri="${warmup_uri}" \ + --schedule="${desired_warmup_schedule}" \ + --time-zone="${market_timezone}" \ + --http-method=GET \ + --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ + --oidc-token-audience="${service_url}" \ + --attempt-deadline=60s \ + --max-retry-attempts=2 \ + --min-backoff=15s \ + --max-backoff=60s \ + --max-doublings=2 \ + --quiet + fi + + precheck_job_name="${cloud_run_service%-service}-precheck-scheduler" + precheck_uri="${service_url}/dry-run" + precheck_retry_attempts=3 + precheck_retry_duration=900s + if [ "${drill_precheck_enabled}" = "true" ]; then + precheck_retry_attempts=0 + precheck_retry_duration=0s + fi + precheck_state="$(gcloud scheduler jobs describe "${precheck_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --format='value(state)' 2>/dev/null || true)" + if [ -n "${precheck_state}" ]; 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}" \ + --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="${precheck_retry_attempts}" \ + --min-backoff=120s \ + --max-backoff=300s \ + --max-retry-duration="${precheck_retry_duration}" \ + --quiet + else + 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="${precheck_retry_attempts}" \ + --min-backoff=120s \ + --max-backoff=300s \ + --max-retry-duration="${precheck_retry_duration}" \ + --quiet + fi + managed_scheduler_jobs=("${job_name}" "${warmup_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)')" + job_enabled="${standard_execution_enabled}" + if [ "${managed_job_name}" = "${precheck_job_name}" ] && [ "${drill_precheck_enabled}" = "true" ]; then + job_enabled=true + fi + case "${job_enabled}" in + 1|true|yes|on) + if [ "${managed_job_state}" = "PAUSED" ]; then + echo "Resuming approved Cloud Scheduler job ${managed_job_name}." + gcloud scheduler jobs resume "${managed_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --quiet + fi + desired_scheduler_state="ENABLED" + ;; + *) + if [ "${managed_job_state}" != "PAUSED" ]; then + echo "Pausing Cloud Scheduler job ${managed_job_name} because it is not approved for this target." + gcloud scheduler jobs pause "${managed_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --quiet + fi + desired_scheduler_state="PAUSED" + ;; + esac + managed_job_state_after="$(gcloud scheduler jobs describe "${managed_job_name}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${scheduler_location}" \ + --format='value(state)')" + if [ "${managed_job_state_after}" != "${desired_scheduler_state}" ]; then + echo "Scheduler state readback failed for ${managed_job_name}: expected ${desired_scheduler_state}, got ${managed_job_state_after:-}." >&2 + exit 1 + fi + done + done + + reconcile_args=( + --platform=ibkr + --project="${GCP_PROJECT_ID}" + --region="${CLOUD_RUN_REGION}" + --scheduler-location="${scheduler_location}" + --delete-legacy-schedulers + ) + if [ "${WORKFLOW_TARGET:-configured}" != "configured" ] || [ -z "${INPUT_CONFIGURED_SERVICE:-}" ]; then + python3 scripts/reconcile_cloud_runtime.py "${reconcile_args[@]}" + fi + + for update in "${scheduler_updates[@]}"; do + IFS=$'\t' read -r cloud_run_service _ <<< "${update}" + legacy_env_names="$(gcloud run services describe "${cloud_run_service}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --format='value(spec.template.spec.containers[0].env[].name)')" + if ! printf '%s\n' "${legacy_env_names}" | tr ';' '\n' | grep -Fxq 'IBKR_MONITOR_DISPATCH_TARGETS_JSON'; then + continue + fi + echo "Removing retired monitor dispatcher configuration from ${cloud_run_service}." + gcloud run services update "${cloud_run_service}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --remove-env-vars=IBKR_MONITOR_DISPATCH_TARGETS_JSON \ + --quiet + done + + - name: Prune old Cloud Run revisions + if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' && (env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '') + env: + SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} + run: | + set -euo pipefail + + mapfile -t target_services < <(python - <<'PY' + import json + import os + + raw_plan = os.environ.get("SYNC_PLAN_JSON", "").strip() + if raw_plan: + plan = json.loads(raw_plan) + for target in plan["targets"]: + print(target["service_name"]) + raise SystemExit(0) + + raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip() + if raw_targets: + payload = json.loads(raw_targets) + entries = payload.get("targets") if isinstance(payload, dict) else payload + for entry in entries: + service = ( + entry.get("service") + or entry.get("service_name") + or entry.get("cloud_run_service") + ) + runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json") + if not service and isinstance(runtime_target, str): + runtime_target = json.loads(runtime_target) + if not service and isinstance(runtime_target, dict): + service = runtime_target.get("service_name") + service = str(service or "").strip() + if service: + print(service) + raise SystemExit(0) + + raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip() + for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","): + service = chunk.strip() + if service: + print(service) + PY + ) + + for cloud_run_service in "${target_services[@]}"; do + keep_revisions="$( + gcloud run services describe "${cloud_run_service}" \ + --region "${CLOUD_RUN_REGION}" \ + --format=json \ + | python -c ' + import json + import sys + service = json.load(sys.stdin) + revisions = { + item.get("revisionName") + for item in service.get("status", {}).get("traffic", []) + if item.get("revisionName") and int(item.get("percent") or 0) > 0 + } + latest_ready = service.get("status", {}).get("latestReadyRevisionName") + if latest_ready: + revisions.add(latest_ready) + for revision in sorted(revisions): + print(revision) + ' + )" + + if [ -z "${keep_revisions}" ]; then + echo "No active Cloud Run revision found for ${cloud_run_service}; skipping revision prune." >&2 + continue + fi + + mapfile -t revisions_to_delete < <( + gcloud run revisions list \ + --service "${cloud_run_service}" \ + --region "${CLOUD_RUN_REGION}" \ + --format='value(metadata.name)' \ + | grep -Fvx -f <(printf '%s\n' "${keep_revisions}") || true + ) + + for revision in "${revisions_to_delete[@]}"; do + echo "Deleting old Cloud Run revision ${revision} for ${cloud_run_service}." + gcloud run revisions delete "${revision}" \ + --region "${CLOUD_RUN_REGION}" \ + --quiet 2>&1 || true + done + done + + - name: Clean up old Cloud Run images + if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' && (env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '') + run: | + set -euo pipefail + + artifact_registry_hostname="${GCP_ARTIFACT_REGISTRY_HOSTNAME:-${CLOUD_RUN_REGION}-docker.pkg.dev}" + image_repo="${artifact_registry_hostname}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/interactivebrokersplatform/cloud-run" + old_digests="$(gcloud artifacts docker images list "${image_repo}" \ + --project="${GCP_PROJECT_ID}" \ + --include-tags \ + --format=json \ + | python -c 'import json,os,sys; keep=os.environ["SOURCE_COMMIT"]; rows=json.load(sys.stdin); print("\n".join(row["version"] for row in rows if not any(keep in tag for tag in (row.get("tags") or []))))')" + + while IFS= read -r digest; do + if [ -z "${digest}" ]; then + continue + fi + gcloud artifacts docker images delete "${image_repo}@${digest}" \ + --project="${GCP_PROJECT_ID}" \ + --delete-tags \ + --async \ + --quiet + done <<< "${old_digests}" diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 7ffdceba..cab96877 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -58,14 +58,6 @@ on: type: boolean default: false -env: - GCP_PROJECT_ID: interactivebrokersquant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com - GCP_RUNTIME_SERVICE_ACCOUNT: ibkr-platform-runtime@interactivebrokersquant.iam.gserviceaccount.com - GCP_SCHEDULER_SERVICE_ACCOUNT: ibkr-platform-scheduler@interactivebrokersquant.iam.gserviceaccount.com - GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy - concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: false @@ -73,1564 +65,33 @@ concurrency: jobs: sync-cloud-run-env: name: Deploy / Sync Cloud Run - # Production deployment and environment sync require an explicit operator dispatch. if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 20 permissions: actions: read contents: read id-token: write - env: - ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }} - ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} - ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} - QSL_ENABLE_CLOUD_RUN_AUTOMATION: ${{ vars.QSL_ENABLE_CLOUD_RUN_AUTOMATION }} - CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }} - WORKFLOW_TARGET: ${{ inputs.target || 'configured' }} - INPUT_CONFIGURED_SERVICE: ${{ inputs.configured_service }} - INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }} - SOURCE_COMMIT: ${{ inputs.source_commit || github.sha }} - INPUT_CLOUD_RUN_REGION: ${{ inputs.cloud_run_region }} - INPUT_CLOUD_RUN_SERVICE: ${{ inputs.cloud_run_service }} - INPUT_ACCOUNT_GROUP: ${{ inputs.account_group }} - INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ inputs.account_group_config_secret_name }} - INPUT_DEPLOY_IMAGE: ${{ inputs.deploy_image }} - INPUT_SYNC_ENV: ${{ inputs.sync_env }} - INPUT_APPROVE_TRAFFIC_SHIFT: ${{ inputs.approve_traffic_shift }} - INPUT_APPROVE_SCHEDULER_SYNC: ${{ inputs.approve_scheduler_sync }} - GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} - CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} + uses: ./.github/workflows/sync-cloud-run-env-protected.yml + with: + target: ${{ inputs.target }} + configured_service: ${{ inputs.configured_service }} + source_commit: ${{ inputs.source_commit }} + cloud_run_region: ${{ inputs.cloud_run_region }} + cloud_run_service: ${{ inputs.cloud_run_service }} + account_group: ${{ inputs.account_group }} + account_group_config_secret_name: ${{ inputs.account_group_config_secret_name }} + deploy_image: ${{ inputs.deploy_image }} + sync_env: ${{ inputs.sync_env }} + approve_traffic_shift: ${{ inputs.approve_traffic_shift }} + approve_scheduler_sync: ${{ inputs.approve_scheduler_sync }} + secrets: CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }} - # This is the validated non-secret service inventory maintained by the - # control plane; legacy Secret support is only a migration fallback. CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} - CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }} - CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} - 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 }} - TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} - # This is validated deployment intent, not credential material. Prefer the - # control-plane variable while retaining a legacy-secret fallback during migration. - RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} - ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }} - IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }} - IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }} - IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }} - IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE }} - IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }} - IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS }} - IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }} - IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }} - IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} - IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} - IBKR_EXECUTION_DEDUP_ENABLED: ${{ vars.IBKR_EXECUTION_DEDUP_ENABLED }} - IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }} - IBKR_FORCE_RUN: ${{ vars.IBKR_FORCE_RUN }} - IBKR_MARKET: ${{ vars.IBKR_MARKET }} - IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} - IBKR_MARKET_CURRENCY: ${{ vars.IBKR_MARKET_CURRENCY }} - IBKR_MARKET_DATA_SYMBOL_SUFFIX: ${{ vars.IBKR_MARKET_DATA_SYMBOL_SUFFIX }} - IBKR_MARKET_EXCHANGE: ${{ vars.IBKR_MARKET_EXCHANGE }} - IBKR_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }} - IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }} - IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }} - IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} - INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} - INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }} - INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} - IBKR_CASH_ONLY_EXECUTION: ${{ vars.IBKR_CASH_ONLY_EXECUTION }} - CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }} - DCA_MODE: ${{ vars.DCA_MODE }} - DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }} - IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }} - IBIT_ZSCORE_EXIT_MODE: ${{ vars.IBIT_ZSCORE_EXIT_MODE }} - IBIT_ZSCORE_EXIT_PARKING_SYMBOL: ${{ vars.IBIT_ZSCORE_EXIT_PARKING_SYMBOL }} - IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }} - IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE }} - IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }} - IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI }} - IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} - IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} - IBKR_MARKET_SIGNAL_CACHE_DIR: ${{ vars.IBKR_MARKET_SIGNAL_CACHE_DIR }} - IBKR_MARKET_SIGNAL_REQUIRED: ${{ vars.IBKR_MARKET_SIGNAL_REQUIRED }} - IBKR_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.IBKR_MARKET_SIGNAL_FALLBACK_MODE }} - IBKR_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.IBKR_MARKET_SIGNAL_MAX_STALE_DAYS }} - STRATEGY_PLUGIN_ALERT_CHANNELS: ${{ vars.STRATEGY_PLUGIN_ALERT_CHANNELS }} - STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS }} - STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL }} - STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST }} - STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT }} - STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY: ${{ vars.STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY }} - STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS }} - STRATEGY_PLUGIN_ALERT_SMS_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_PROVIDER }} - STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID }} - STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_SMS_SENDER: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_SENDER }} - STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID }} - STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL }} - STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS }} - STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS }} - STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER }} - STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL }} - STRATEGY_PLUGIN_ALERT_PUSH_DEVICE: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_DEVICE }} - STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY }} - STRATEGY_PLUGIN_ALERT_PUSH_TAGS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_TAGS }} - STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS }} - # Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs. - EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} - # Empty by default. When explicitly set, the workflow downloads one - # immutable, private recovery ledger before deriving its sync plan. - IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI: ${{ vars.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }} GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} - NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} - TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD }} - STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} - STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN }} - STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.TG_TOKEN }} - steps: - - name: Check whether Cloud Run automation is enabled - id: config - run: | - set -euo pipefail - - write_github_output() { - printf '%s\n' "$@" >> "$GITHUB_OUTPUT" - } - - deploy_enabled=false - env_sync_enabled=false - traffic_shift_enabled=false - scheduler_sync_enabled=false - - # QSL_ENABLE_CLOUD_RUN_AUTOMATION overrides ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION - ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION="${QSL_ENABLE_CLOUD_RUN_AUTOMATION:-$ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION}" - if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION:-}" != "true" ]; then - write_github_output \ - "deploy_enabled=false" \ - "env_sync_enabled=false" \ - "traffic_shift_enabled=false" \ - "scheduler_sync_enabled=false" \ - "enabled=false" - echo "Skipping Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2 - exit 0 - fi - - if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${WORKFLOW_TARGET:-configured}" = "hk-verify" ]; then - if [ "${INPUT_DEPLOY_IMAGE:-true}" = "true" ]; then - deploy_enabled=true - fi - if [ "${INPUT_SYNC_ENV:-true}" = "true" ]; then - env_sync_enabled=true - fi - else - if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ] \ - && { [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ] || [ "${INPUT_DEPLOY_IMAGE:-true}" = "true" ]; }; then - deploy_enabled=true - fi - - if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ] \ - && { [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ] || [ "${INPUT_SYNC_ENV:-true}" = "true" ]; }; then - env_sync_enabled=true - fi - fi - - if [ "${INPUT_APPROVE_TRAFFIC_SHIFT:-false}" = "true" ]; then - traffic_shift_enabled=true - fi - if [ "${INPUT_APPROVE_SCHEDULER_SYNC:-false}" = "true" ]; then - scheduler_sync_enabled=true - fi - - if [ "${WORKFLOW_TARGET:-configured}" = "configured" ] && [ -z "${INPUT_CONFIGURED_SERVICE:-}" ]; then - if [ "${deploy_enabled}" = "true" ] || [ "${env_sync_enabled}" = "true" ] || [ "${traffic_shift_enabled}" = "true" ] || [ "${scheduler_sync_enabled}" = "true" ]; then - echo "configured target requires exact configured_service; refusing multi-service default mutations." >&2 - exit 1 - fi - fi - - if { [ "${deploy_enabled}" = "true" ] || [ "${env_sync_enabled}" = "true" ] || [ "${traffic_shift_enabled}" = "true" ] || [ "${scheduler_sync_enabled}" = "true" ]; } \ - && [ "${GITHUB_REF:-}" != "refs/heads/main" ]; then - echo "Cloud Run mutations require the trusted main workflow ref." >&2 - exit 1 - fi - - if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${deploy_enabled}" = "true" ] \ - && [ -n "${INPUT_SOURCE_COMMIT:-}" ] \ - && [[ ! "${INPUT_SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then - echo "Explicit source_commit must be a full 40-character SHA." >&2 - exit 1 - fi - - write_github_output \ - "deploy_enabled=${deploy_enabled}" \ - "env_sync_enabled=${env_sync_enabled}" \ - "traffic_shift_enabled=${traffic_shift_enabled}" \ - "scheduler_sync_enabled=${scheduler_sync_enabled}" - - if [ "${deploy_enabled}" != "true" ] && [ "${env_sync_enabled}" != "true" ] && [ "${traffic_shift_enabled}" != "true" ] && [ "${scheduler_sync_enabled}" != "true" ]; then - write_github_output "enabled=false" - echo "Skipping Cloud Run automation because ENABLE_GITHUB_CLOUD_RUN_DEPLOY and ENABLE_GITHUB_ENV_SYNC are not true." >&2 - exit 0 - fi - - write_github_output "enabled=true" - - - name: Checkout repository - if: steps.config.outputs.enabled == 'true' - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - - - name: Checkout selected image source - if: steps.config.outputs.deploy_enabled == 'true' - uses: actions/checkout@v6 - with: - repository: ${{ github.repository }} - ref: ${{ env.SOURCE_COMMIT }} - path: source - persist-credentials: false - - - name: Verify selected image source - if: steps.config.outputs.deploy_enabled == 'true' - run: | - set -euo pipefail - if [[ ! "${SOURCE_COMMIT:-}" =~ ^[0-9a-f]{40}$ ]]; then - echo "Selected source must be a full commit SHA." >&2 - exit 1 - fi - checked_out_sha="$(git -C source rev-parse HEAD)" - if [ "${checked_out_sha}" != "${SOURCE_COMMIT}" ]; then - echo "Checked-out image source does not match source_commit." >&2 - exit 1 - fi - - - name: Apply HK verify-only dispatch defaults - if: steps.config.outputs.enabled == 'true' && github.event_name == 'workflow_dispatch' && inputs.target == 'hk-verify' - run: | - set -euo pipefail - - service="${INPUT_CLOUD_RUN_SERVICE:-interactive-brokers-hk-verify-service}" - account_group="${INPUT_ACCOUNT_GROUP:-hk-verify}" - region="${INPUT_CLOUD_RUN_REGION:-${CLOUD_RUN_REGION:-}}" - runtime_target="$(SERVICE="${service}" ACCOUNT_GROUP_VALUE="${account_group}" python3 - <<'PY' - import json - import os - - print( - json.dumps( - { - "platform_id": "ibkr", - "strategy_profile": "hk_global_etf_tactical_rotation", - "deployment_selector": os.environ["ACCOUNT_GROUP_VALUE"], - "account_scope": os.environ["ACCOUNT_GROUP_VALUE"], - "execution_mode": "paper", - "dry_run_only": True, - "service_name": os.environ["SERVICE"], - }, - separators=(",", ":"), - ) - ) - PY - )" - - { - echo "CLOUD_RUN_SERVICE=${service}" - echo "CLOUD_RUN_SERVICES=" - echo "CLOUD_RUN_SERVICE_TARGETS_JSON=" - echo "ACCOUNT_GROUP=${account_group}" - echo "RUNTIME_TARGET_JSON=${runtime_target}" - echo "IBKR_DRY_RUN_ONLY=true" - echo "IBKR_MARKET=HK" - echo "IBKR_MARKET_CALENDAR=XHKG" - echo "IBKR_MARKET_CURRENCY=HKD" - echo "IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK" - echo "IBKR_MARKET_EXCHANGE=SEHK" - echo "IBKR_MARKET_TIMEZONE=Asia/Hong_Kong" - } >> "$GITHUB_ENV" - - if [ -n "${region}" ]; then - echo "CLOUD_RUN_REGION=${region}" >> "$GITHUB_ENV" - fi - if [ -n "${INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME:-}" ]; then - echo "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=${INPUT_ACCOUNT_GROUP_CONFIG_SECRET_NAME}" >> "$GITHUB_ENV" - fi - if [ "${INPUT_DEPLOY_IMAGE:-true}" != "true" ]; then - echo "CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT=false" >> "$GITHUB_ENV" - fi - - - name: Set up Python for runtime target admission - if: steps.config.outputs.enabled == 'true' - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Authenticate to Google Cloud - id: auth - if: steps.config.outputs.enabled == 'true' - 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 - if: steps.config.outputs.enabled == 'true' - uses: google-github-actions/setup-gcloud@v3 - with: - project_id: ${{ env.GCP_PROJECT_ID }} - version: ">= 416.0.0" - - - name: Fetch opt-in immutable recovery state ledger - if: steps.config.outputs.enabled == 'true' && env.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI != '' - env: - RECOVERY_STATE_LEDGER_URI: ${{ env.IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI }} - run: | - set -euo pipefail - case "${RECOVERY_STATE_LEDGER_URI}" in - gs://qsl-reconciliation-recovery-private-ibkr/reconciliation-recovery/ibkr/state/*.json) ;; - *) - echo "Recovery state ledger URI must use the dedicated private immutable prefix." >&2 - exit 1 - ;; - esac - recovery_state_ledger_path="${RUNNER_TEMP}/ibkr-reconciliation-recovery-state-ledger.json" - gcloud storage cp --quiet "${RECOVERY_STATE_LEDGER_URI}" "${recovery_state_ledger_path}" >/dev/null - chmod 600 "${recovery_state_ledger_path}" - echo "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PATH=${recovery_state_ledger_path}" >> "$GITHUB_ENV" - - - name: Install strategy status dependencies - if: steps.config.outputs.enabled == 'true' - run: | - set -euo pipefail - python -m pip install --upgrade pip uv - uv sync --frozen --no-dev - - name: Resolve admissible Cloud Run targets - id: strategy_requirements - if: steps.config.outputs.enabled == 'true' - run: | - set -euo pipefail - sync_plan_json="$(uv run --no-sync python scripts/build_cloud_run_env_sync_plan.py --json)" - export SYNC_PLAN_JSON="${sync_plan_json}" - python - <<'PY' - import json - import os - import re - from pathlib import Path - - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - selected = os.environ.get("INPUT_CONFIGURED_SERVICE", "") - if os.environ.get("WORKFLOW_TARGET", "configured") == "configured": - if not selected: - raise SystemExit("configured target requires exact configured_service matching one admitted inventory service_name") - if not re.fullmatch(r"[a-z][a-z0-9-]{0,62}", selected) or plan.get("mode") != "per_service": - raise SystemExit("configured_service requires an exact private inventory service_name") - matches = [target for target in plan["targets"] if target.get("service_name") == selected] - if len(matches) != 1: - raise SystemExit("configured_service must match exactly one admitted inventory target") - plan["targets"] = matches - # Reconciliation also reads inventory and legacy env; scope every source. - inventory = json.dumps({"targets": [{"service_name": selected}]}) - with Path(os.environ["GITHUB_ENV"]).open("a") as handle: - handle.write(f"CLOUD_RUN_SERVICE_TARGETS_JSON={inventory}\n") - handle.write(f"CLOUD_RUN_SERVICE={selected}\nCLOUD_RUN_SERVICES={selected}\n") - with Path(os.environ["GITHUB_OUTPUT"]).open("a") as handle: - handle.write("sync_plan_json<<__SYNC_PLAN_JSON__\n") - handle.write(json.dumps(plan) + "\n__SYNC_PLAN_JSON__\n") - PY - - - name: Validate env sync inputs - if: steps.config.outputs.env_sync_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - - required_vars=( - CLOUD_RUN_REGION - ) - - missing_vars=() - for var_name in "${required_vars[@]}"; do - if [ -z "${!var_name:-}" ]; then - missing_vars+=("${var_name}") - fi - done - - if [ -z "${TELEGRAM_TOKEN_SECRET_NAME:-}" ] && [ -z "${TELEGRAM_TOKEN:-}" ]; then - missing_vars+=("TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN") - fi - - python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - targets = plan.get("targets") or [] - if not targets: - raise SystemExit("Cloud Run env sync did not resolve any targets") - for target in targets: - service_name = str(target.get("service_name") or "").strip() - if not service_name: - raise SystemExit("Cloud Run sync target is missing service_name") - if not isinstance(target.get("env"), dict): - raise SystemExit(f"Cloud Run sync target {service_name} is missing env") - PY - - if [ "${#missing_vars[@]}" -gt 0 ]; then - echo "Cloud Run env sync is enabled, but these values are missing:" >&2 - printf ' - %s\n' "${missing_vars[@]}" >&2 - exit 1 - fi - - - name: Validate deploy inputs - if: steps.config.outputs.deploy_enabled == 'true' - run: | - set -euo pipefail - - if [ -z "${CLOUD_RUN_REGION:-}" ]; then - echo "Cloud Run deploy is enabled, but CLOUD_RUN_REGION is missing." >&2 - exit 1 - fi - - mapfile -t target_services < <(python - <<'PY' - import json - import os - - raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip() - if raw_targets: - payload = json.loads(raw_targets) - entries = payload.get("targets") if isinstance(payload, dict) else payload - if not isinstance(entries, list): - raise SystemExit("CLOUD_RUN_SERVICE_TARGETS_JSON must be a JSON array or an object with targets") - for entry in entries: - if not isinstance(entry, dict): - raise SystemExit("Each Cloud Run deploy target must be an object") - service = ( - entry.get("service") - or entry.get("service_name") - or entry.get("cloud_run_service") - ) - runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json") - if not service and isinstance(runtime_target, str): - runtime_target = json.loads(runtime_target) - if not service and isinstance(runtime_target, dict): - service = runtime_target.get("service_name") - service = str(service or "").strip() - if service: - print(service) - raise SystemExit(0) - - raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip() - for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","): - service = chunk.strip() - if service: - print(service) - PY - ) - - if [ "${#target_services[@]}" -eq 0 ]; then - echo "Cloud Run deploy is enabled, but no services were found in CLOUD_RUN_SERVICE_TARGETS_JSON, CLOUD_RUN_SERVICES, or CLOUD_RUN_SERVICE." >&2 - exit 1 - fi - - - name: Require configured scheduler migration before HK verify - if: steps.config.outputs.env_sync_enabled == 'true' && env.WORKFLOW_TARGET == 'hk-verify' - run: | - set -euo pipefail - scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" - if gcloud scheduler jobs describe "interactive-brokers-monitor-dispatcher-scheduler" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" >/dev/null 2>&1; then - echo "HK verify scheduler migration requires the configured migration first." >&2 - exit 1 - fi - - - name: Capture no-traffic deployment baseline - if: steps.config.outputs.deploy_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - DEPLOY_READBACK_DIR: ${{ runner.temp }}/cloud-run-no-traffic-baseline - run: | - set -euo pipefail - mkdir -p "${DEPLOY_READBACK_DIR}" - mapfile -t target_services < <(python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - for target in plan["targets"]: - print(target["service_name"]) - PY - ) - if [ "${#target_services[@]}" -eq 0 ]; then - echo "Cloud Run deploy did not resolve any targets." >&2 - exit 1 - fi - for cloud_run_service in "${target_services[@]}"; do - python3 scripts/verify_cloud_run_no_traffic_deploy.py capture \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --service="${cloud_run_service}" \ - --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ - --output="${DEPLOY_READBACK_DIR}/${cloud_run_service}.json" - done - - - name: Verify deployed runtime target admission before traffic shift - if: steps.config.outputs.deploy_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - uv run --no-sync python scripts/verify_deployed_runtime_target_admission.py \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" - - - name: Build, push, and deploy Cloud Run image - id: deploy - if: steps.config.outputs.deploy_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - - mapfile -t target_services < <(python - <<'PY' - import json - import os - - raw_plan = os.environ.get("SYNC_PLAN_JSON", "").strip() - if raw_plan: - plan = json.loads(raw_plan) - for target in plan["targets"]: - service = str(target.get("service_name") or "").strip() - if service: - print(service) - raise SystemExit(0) - - raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip() - if raw_targets: - payload = json.loads(raw_targets) - entries = payload.get("targets") if isinstance(payload, dict) else payload - for entry in entries: - service = ( - entry.get("service") - or entry.get("service_name") - or entry.get("cloud_run_service") - ) - runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json") - if not service and isinstance(runtime_target, str): - runtime_target = json.loads(runtime_target) - if not service and isinstance(runtime_target, dict): - service = runtime_target.get("service_name") - service = str(service or "").strip() - if service: - print(service) - raise SystemExit(0) - - raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip() - for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","): - service = chunk.strip() - if service: - print(service) - PY - ) - if [ "${#target_services[@]}" -eq 0 ]; then - echo "Cloud Run deploy did not resolve any targets." >&2 - exit 1 - fi - - artifact_registry_hostname="${GCP_ARTIFACT_REGISTRY_HOSTNAME:-${CLOUD_RUN_REGION}-docker.pkg.dev}" - image_repo="${artifact_registry_hostname}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/interactivebrokersplatform/cloud-run" - image="${image_repo}:${SOURCE_COMMIT}-${GITHUB_RUN_ID}" - - gcloud auth configure-docker "${artifact_registry_hostname}" --quiet - docker build --pull -t "${image}" source - # RS-08 guard: block the deploy if the image's installed UES revision differs from - # strategy_release / runtime-risk binding (live would reject every SOXL rebalance). - python3 scripts/verify_image_ues_release_binding.py --image "${image}" --lockfile source/uv.lock - docker push "${image}" - image_digest="$(gcloud artifacts docker images describe "${image}" --project="${GCP_PROJECT_ID}" --format='value(image_summary.digest)')" - if [[ ! "${image_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "Artifact Registry did not return a valid image digest." >&2 - exit 1 - fi - immutable_image="${image_repo}@${image_digest}" - echo "image_digest=${image_digest}" >> "${GITHUB_OUTPUT}" - - for cloud_run_service in "${target_services[@]}"; do - gcloud run deploy "${cloud_run_service}" \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --platform=managed \ - --image="${immutable_image}" \ - --service-account="${GCP_RUNTIME_SERVICE_ACCOUNT}" \ - --no-allow-unauthenticated \ - --ingress=internal \ - --max-instances=1 \ - --concurrency=1 \ - --memory=512Mi \ - --cpu=1 \ - --timeout=300s \ - --network=default \ - --subnet=default \ - --vpc-egress=private-ranges-only \ - --labels="managed-by=github-actions,commit-sha=${SOURCE_COMMIT},github-run-id=${GITHUB_RUN_ID}" \ - --no-traffic \ - --quiet - done - - - name: Verify no-traffic deployment readback - if: steps.config.outputs.deploy_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - DEPLOY_READBACK_DIR: ${{ runner.temp }}/cloud-run-no-traffic-baseline - EXPECTED_IMAGE_DIGEST: ${{ steps.deploy.outputs.image_digest }} - run: | - set -euo pipefail - mapfile -t target_services < <(python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - for target in plan["targets"]: - print(target["service_name"]) - PY - ) - for cloud_run_service in "${target_services[@]}"; do - python3 scripts/verify_cloud_run_no_traffic_deploy.py verify \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --service="${cloud_run_service}" \ - --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ - --before="${DEPLOY_READBACK_DIR}/${cloud_run_service}.json" \ - --expected-sha="${SOURCE_COMMIT}" \ - --expected-image-digest="${EXPECTED_IMAGE_DIGEST}" - done - - - name: Wait for Cloud Run deployment of current commit - if: steps.config.outputs.deploy_enabled == 'true' && steps.config.outputs.env_sync_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - - mapfile -t target_services < <(python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - for target in plan["targets"]: - print(target["service_name"]) - PY - ) - if [ "${#target_services[@]}" -eq 0 ]; then - echo "Cloud Run env sync did not resolve any targets." >&2 - exit 1 - fi - - case "${CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT:-true}" in - false|False|FALSE|0|no|No|NO|off|Off|OFF) - echo "Skipping Cloud Run commit wait because CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT is disabled." - exit 0 - ;; - esac - - target_sha="${SOURCE_COMMIT}" - - for cloud_run_service in "${target_services[@]}"; do - deadline=$((SECONDS + 1800)) - - while true; do - deployed_sha="$(gcloud run services describe "${cloud_run_service}" --region "${CLOUD_RUN_REGION}" --format='value(spec.template.metadata.labels.commit-sha)' 2>/dev/null || true)" - if [ -n "${deployed_sha}" ] && [ "${deployed_sha}" = "${target_sha}" ]; then - echo "Cloud Run service ${cloud_run_service} is on commit ${deployed_sha}." - break - fi - - if [ "${SECONDS}" -ge "${deadline}" ]; then - echo "Timed out waiting for Cloud Run service ${cloud_run_service} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 - exit 1 - fi - - echo "Waiting for Cloud Run service ${cloud_run_service} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}" >&2 - sleep 10 - done - done - - - name: Sync Cloud Run environment - if: steps.config.outputs.env_sync_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - - mapfile -t target_services < <(python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - for target in plan["targets"]: - print(target["service_name"]) - PY - ) - if [ "${#target_services[@]}" -eq 0 ]; then - echo "Cloud Run env sync did not resolve any targets." >&2 - exit 1 - fi - - emit_target_env_pairs() { - local target_index="$1" - SYNC_TARGET_INDEX="${target_index}" python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] - for key, value in sorted(target["env"].items()): - print(f"{key}={value}") - PY - } - - emit_target_remove_env_vars() { - local target_index="$1" - SYNC_TARGET_INDEX="${target_index}" python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] - for key in sorted(target.get("remove_env_vars") or []): - print(key) - PY - } - - emit_target_label_pairs() { - local target_index="$1" - SYNC_TARGET_INDEX="${target_index}" python - <<'PY' - import json - import os - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - target = plan["targets"][int(os.environ["SYNC_TARGET_INDEX"])] - strategy_profile = str(target.get("strategy_profile") or "").strip() - if strategy_profile: - print(f"strategy={strategy_profile}") - PY - } - - join_by_delimiter() { - local delimiter="$1" - shift - local output="" - local item - for item in "$@"; do - if [ -z "${output}" ]; then - output="${item}" - else - output="${output}${delimiter}${item}" - fi - done - printf '%s' "${output}" - } - - shared_env_pairs=() - shared_secret_pairs=() - - shared_remove_env_vars=( - "IB_GATEWAY_HOST" - "IB_GATEWAY_PORT" - "TELEGRAM_CHAT_ID" - "IB_CLIENT_ID" - "IB_GATEWAY_INSTANCE_NAME" - "IB_GATEWAY_MODE" - "CRISIS_ALERT_GOOGLE_VOICE_TO" - "CRISIS_ALERT_GOOGLE_VOICE_GATEWAY" - "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER" - "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" - "CRISIS_ALERT_GOOGLE_VOICE_RECIPIENTS" - "CRISIS_ALERT_GOOGLE_VOICE_SENDER_EMAIL" - "CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD" - "CRISIS_ALERT_GOOGLE_VOICE_SMTP_HOST" - "CRISIS_ALERT_GOOGLE_VOICE_SMTP_PORT" - "CRISIS_ALERT_GOOGLE_VOICE_SMTP_SECURITY" - "CRISIS_ALERT_SMTP_FROM" - "CRISIS_ALERT_SMTP_HOST" - "CRISIS_ALERT_SMTP_PORT" - "CRISIS_ALERT_SMTP_USERNAME" - "CRISIS_ALERT_SMTP_PASSWORD" - "CRISIS_ALERT_SMTP_STARTTLS" - "CRISIS_ALERT_SMTP_SSL" - "CRISIS_ALERT_CHANNELS" - "CRISIS_ALERT_EMAIL_RECIPIENTS" - "CRISIS_ALERT_EMAIL_SENDER_EMAIL" - "CRISIS_ALERT_EMAIL_SENDER_PASSWORD" - "CRISIS_ALERT_EMAIL_SMTP_HOST" - "CRISIS_ALERT_EMAIL_SMTP_PORT" - "CRISIS_ALERT_EMAIL_SMTP_SECURITY" - "CRISIS_ALERT_SMS_RECIPIENTS" - "CRISIS_ALERT_SMS_PROVIDER" - "CRISIS_ALERT_SMS_ACCOUNT_ID" - "CRISIS_ALERT_SMS_AUTH_TOKEN" - "CRISIS_ALERT_SMS_SENDER" - "CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID" - "CRISIS_ALERT_SMS_API_BASE_URL" - "CRISIS_ALERT_SMS_BODY_MAX_CHARS" - "CRISIS_ALERT_PUSH_RECIPIENTS" - "CRISIS_ALERT_PUSH_PROVIDER" - "CRISIS_ALERT_PUSH_APP_TOKEN" - "CRISIS_ALERT_PUSH_ACCESS_TOKEN" - "CRISIS_ALERT_PUSH_API_BASE_URL" - "CRISIS_ALERT_PUSH_DEVICE" - "CRISIS_ALERT_PUSH_PRIORITY" - "CRISIS_ALERT_PUSH_TAGS" - "CRISIS_ALERT_PUSH_BODY_MAX_CHARS" - "CRISIS_ALERT_TELEGRAM_CHAT_IDS" - "CRISIS_ALERT_TELEGRAM_BOT_TOKEN" - "CRISIS_ALERT_TELEGRAM_API_BASE_URL" - "CRISIS_ALERT_TELEGRAM_PARSE_MODE" - "CRISIS_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW" - "CRISIS_ALERT_TELEGRAM_BODY_MAX_CHARS" - ) - shared_remove_secret_vars=( - "CRISIS_ALERT_SMTP_PASSWORD" - "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" - "CRISIS_ALERT_GOOGLE_VOICE_SENDER_PASSWORD" - "CRISIS_ALERT_EMAIL_SENDER_PASSWORD" - "CRISIS_ALERT_SMS_AUTH_TOKEN" - "CRISIS_ALERT_PUSH_APP_TOKEN" - "CRISIS_ALERT_PUSH_ACCESS_TOKEN" - "CRISIS_ALERT_TELEGRAM_BOT_TOKEN" - ) - - if [ -n "${TELEGRAM_TOKEN_SECRET_NAME:-}" ]; then - shared_secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest") - else - shared_env_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN}") - shared_remove_secret_vars+=("TELEGRAM_TOKEN") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME:-}" ]; then - shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest") - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") - elif [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD}") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME:-}" ]; then - shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME}:latest") - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") - elif [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN}") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME:-}" ]; then - shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest") - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") - elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN}") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME:-}" ]; then - shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest") - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") - elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN}") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME:-}" ]; then - shared_secret_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME}:latest") - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") - elif [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN}") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") - shared_remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_CHANNELS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_CHANNELS=${STRATEGY_PLUGIN_ALERT_CHANNELS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_CHANNELS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER=${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID=${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_SENDER:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER=${STRATEGY_PLUGIN_ALERT_SMS_SENDER}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID=${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER=${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE=${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY=${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_TAGS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS=${STRATEGY_PLUGIN_ALERT_PUSH_TAGS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE=${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW=${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW") - fi - - if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS:-}" ]; then - shared_env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS}") - else - shared_remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS") - fi - - for target_index in "${!target_services[@]}"; do - cloud_run_service="${target_services[${target_index}]}" - mapfile -t target_env_pairs < <(emit_target_env_pairs "${target_index}") - mapfile -t target_remove_env_vars < <(emit_target_remove_env_vars "${target_index}") - mapfile -t target_label_pairs < <(emit_target_label_pairs "${target_index}") - env_pairs=("${target_env_pairs[@]}" "${shared_env_pairs[@]}") - remove_env_vars=("${target_remove_env_vars[@]}" "${shared_remove_env_vars[@]}") - secret_pairs=("${shared_secret_pairs[@]}") - remove_secret_vars=("${shared_remove_secret_vars[@]}") - - gcloud_args=( - run services update "${cloud_run_service}" - --region "${CLOUD_RUN_REGION}" - --concurrency 1 - --max-instances 1 - --no-traffic - --remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")" - --update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")" - ) - - if [ "${#remove_secret_vars[@]}" -gt 0 ]; then - gcloud_args+=(--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")") - fi - - if [ "${#secret_pairs[@]}" -gt 0 ]; then - gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")") - fi - - if [ "${#target_label_pairs[@]}" -gt 0 ]; then - gcloud_args+=(--update-labels "$(IFS=,; echo "${target_label_pairs[*]}")") - fi - - gcloud "${gcloud_args[@]}" - done - - - name: Reconcile Cloud Run traffic - if: steps.config.outputs.traffic_shift_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - python3 scripts/reconcile_cloud_runtime.py \ - --platform=ibkr \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --ensure-latest-traffic - - - name: Verify strategy plugin mounts - if: steps.config.outputs.env_sync_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - STRATEGY_PLUGIN_MOUNT_ENV_NAMES: IBKR_STRATEGY_PLUGIN_MOUNTS_JSON - run: | - set -euo pipefail - python3 scripts/verify_cloud_run_strategy_plugin_mounts.py - - - name: Sync Cloud Scheduler schedule - if: steps.config.outputs.scheduler_sync_enabled == 'true' - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - - scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" - if [ -z "${scheduler_location}" ]; then - echo "Cloud Scheduler schedule sync requires CLOUD_RUN_REGION or CLOUD_SCHEDULER_LOCATION." >&2 - exit 1 - fi - if [ -z "${GCP_SCHEDULER_SERVICE_ACCOUNT:-}" ]; then - echo "Cloud Scheduler schedule sync requires GCP_SCHEDULER_SERVICE_ACCOUNT." >&2 - exit 1 - fi - - mapfile -t scheduler_updates < <(python - <<'PY' - import json - import os - - def configured_time(name: str, default: str) -> str: - return os.environ.get(name, "").strip() or default - - plan = json.loads(os.environ["SYNC_PLAN_JSON"]) - for target in plan["targets"]: - service_name = str(target["service_name"]).strip() - env = target.get("env") or {} - scheduler = target.get("scheduler") or {} - timezone = str(scheduler.get("timezone") or env.get("IBKR_MARKET_TIMEZONE") or "").strip() - market = str(env.get("IBKR_MARKET") or "").strip().upper() - if not timezone: - timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York" - print( - "\t".join( - [ - service_name, - timezone, - str(scheduler.get("main_time") or configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15 * * 1-5")), - str(scheduler.get("probe_time") or configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15 * * 1-5")), - str(scheduler.get("precheck_time") or configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9 * * 1-5")), - str(bool(target.get("standard_execution_enabled", False))).lower(), - str(bool(target.get("drill_precheck_enabled", False))).lower(), - str(scheduler.get("attempt_deadline") or ""), - ] - ) - ) - PY - ) - - for update in "${scheduler_updates[@]}"; do - IFS=$'\t' read -r cloud_run_service market_timezone main_time warmup_time precheck_time standard_execution_enabled drill_precheck_enabled main_attempt_deadline <<< "${update}" - if [ -z "${cloud_run_service}" ] || [ -z "${market_timezone}" ]; then - continue - fi - - service_url="$(gcloud run services describe "${cloud_run_service}" \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --format='value(status.url)' 2>/dev/null || true)" - if [ -z "${service_url}" ]; then - echo "Unable to resolve Cloud Run service URL for ${cloud_run_service}; cannot sync scheduler URI." >&2 - exit 1 - fi - gcloud run services add-iam-policy-binding "${cloud_run_service}" \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --member="serviceAccount:${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ - --role="roles/run.invoker" \ - --quiet - gcloud run services add-iam-policy-binding "${cloud_run_service}" \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --member="serviceAccount:${GCP_RUNTIME_SERVICE_ACCOUNT}" \ - --role="roles/run.invoker" \ - --quiet - - scheduler_job_candidates=() - if [[ "${cloud_run_service}" == *-service ]]; then - scheduler_job_candidates+=("${cloud_run_service%-service}-scheduler") - fi - scheduler_job_candidates+=("${cloud_run_service}-scheduler") - - job_name="" - current_schedule="" - job_exists="false" - for candidate_job in "${scheduler_job_candidates[@]}"; do - current_schedule="$(gcloud scheduler jobs describe "${candidate_job}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --format='value(schedule)' 2>/dev/null || true)" - if [ -n "${current_schedule}" ]; then - job_name="${candidate_job}" - job_exists="true" - break - fi - done - - if [ -z "${current_schedule}" ]; then - job_name="${scheduler_job_candidates[0]}" - current_schedule="0 0 * * *" - fi - - desired_schedule="$(CURRENT_SCHEDULE="${current_schedule}" SCHEDULE_TIME="${main_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 - )" - - desired_warmup_schedule="$(CURRENT_SCHEDULE="${desired_schedule}" SCHEDULE_TIME="${warmup_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 - )" - - 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" - main_attempt_deadline_args=() - if [ -n "${main_attempt_deadline}" ]; then - main_attempt_deadline_args+=(--attempt-deadline="${main_attempt_deadline}") - fi - if [ "${job_exists}" = "true" ]; then - echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule}, timezone to ${market_timezone}, and URI to ${scheduler_uri}." - gcloud scheduler jobs update http "${job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --uri="${scheduler_uri}" \ - --schedule="${desired_schedule}" \ - --time-zone="${market_timezone}" \ - --http-method=POST \ - --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ - --oidc-token-audience="${service_url}" \ - "${main_attempt_deadline_args[@]}" \ - --quiet - else - echo "Creating Cloud Scheduler job ${job_name} schedule ${desired_schedule}, timezone ${market_timezone}, and URI ${scheduler_uri}." - gcloud scheduler jobs create http "${job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --uri="${scheduler_uri}" \ - --schedule="${desired_schedule}" \ - --time-zone="${market_timezone}" \ - --http-method=POST \ - --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ - --oidc-token-audience="${service_url}" \ - "${main_attempt_deadline_args[@]}" \ - --quiet - fi - - warmup_job_name="${cloud_run_service%-service}-warmup-scheduler" - warmup_uri="${service_url}/health" - if gcloud scheduler jobs describe "${warmup_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" >/dev/null 2>&1; then - echo "Updating Cloud Scheduler warmup ${warmup_job_name} to ${desired_warmup_schedule}." - gcloud scheduler jobs update http "${warmup_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --uri="${warmup_uri}" \ - --schedule="${desired_warmup_schedule}" \ - --time-zone="${market_timezone}" \ - --http-method=GET \ - --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ - --oidc-token-audience="${service_url}" \ - --attempt-deadline=60s \ - --max-retry-attempts=2 \ - --min-backoff=15s \ - --max-backoff=60s \ - --max-doublings=2 \ - --quiet - else - echo "Creating Cloud Scheduler warmup ${warmup_job_name} at ${desired_warmup_schedule}." - gcloud scheduler jobs create http "${warmup_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --uri="${warmup_uri}" \ - --schedule="${desired_warmup_schedule}" \ - --time-zone="${market_timezone}" \ - --http-method=GET \ - --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ - --oidc-token-audience="${service_url}" \ - --attempt-deadline=60s \ - --max-retry-attempts=2 \ - --min-backoff=15s \ - --max-backoff=60s \ - --max-doublings=2 \ - --quiet - fi - - precheck_job_name="${cloud_run_service%-service}-precheck-scheduler" - precheck_uri="${service_url}/dry-run" - precheck_retry_attempts=3 - precheck_retry_duration=900s - if [ "${drill_precheck_enabled}" = "true" ]; then - precheck_retry_attempts=0 - precheck_retry_duration=0s - fi - precheck_state="$(gcloud scheduler jobs describe "${precheck_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --format='value(state)' 2>/dev/null || true)" - if [ -n "${precheck_state}" ]; 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}" \ - --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="${precheck_retry_attempts}" \ - --min-backoff=120s \ - --max-backoff=300s \ - --max-retry-duration="${precheck_retry_duration}" \ - --quiet - else - 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="${precheck_retry_attempts}" \ - --min-backoff=120s \ - --max-backoff=300s \ - --max-retry-duration="${precheck_retry_duration}" \ - --quiet - fi - managed_scheduler_jobs=("${job_name}" "${warmup_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)')" - job_enabled="${standard_execution_enabled}" - if [ "${managed_job_name}" = "${precheck_job_name}" ] && [ "${drill_precheck_enabled}" = "true" ]; then - job_enabled=true - fi - case "${job_enabled}" in - 1|true|yes|on) - if [ "${managed_job_state}" = "PAUSED" ]; then - echo "Resuming approved Cloud Scheduler job ${managed_job_name}." - gcloud scheduler jobs resume "${managed_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --quiet - fi - desired_scheduler_state="ENABLED" - ;; - *) - if [ "${managed_job_state}" != "PAUSED" ]; then - echo "Pausing Cloud Scheduler job ${managed_job_name} because it is not approved for this target." - gcloud scheduler jobs pause "${managed_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --quiet - fi - desired_scheduler_state="PAUSED" - ;; - esac - managed_job_state_after="$(gcloud scheduler jobs describe "${managed_job_name}" \ - --project="${GCP_PROJECT_ID}" \ - --location="${scheduler_location}" \ - --format='value(state)')" - if [ "${managed_job_state_after}" != "${desired_scheduler_state}" ]; then - echo "Scheduler state readback failed for ${managed_job_name}: expected ${desired_scheduler_state}, got ${managed_job_state_after:-}." >&2 - exit 1 - fi - done - done - - reconcile_args=( - --platform=ibkr - --project="${GCP_PROJECT_ID}" - --region="${CLOUD_RUN_REGION}" - --scheduler-location="${scheduler_location}" - --delete-legacy-schedulers - ) - if [ "${WORKFLOW_TARGET:-configured}" != "configured" ] || [ -z "${INPUT_CONFIGURED_SERVICE:-}" ]; then - python3 scripts/reconcile_cloud_runtime.py "${reconcile_args[@]}" - fi - - for update in "${scheduler_updates[@]}"; do - IFS=$'\t' read -r cloud_run_service _ <<< "${update}" - legacy_env_names="$(gcloud run services describe "${cloud_run_service}" \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --format='value(spec.template.spec.containers[0].env[].name)')" - if ! printf '%s\n' "${legacy_env_names}" | tr ';' '\n' | grep -Fxq 'IBKR_MONITOR_DISPATCH_TARGETS_JSON'; then - continue - fi - echo "Removing retired monitor dispatcher configuration from ${cloud_run_service}." - gcloud run services update "${cloud_run_service}" \ - --project="${GCP_PROJECT_ID}" \ - --region="${CLOUD_RUN_REGION}" \ - --remove-env-vars=IBKR_MONITOR_DISPATCH_TARGETS_JSON \ - --quiet - done - - - name: Prune old Cloud Run revisions - if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' && (env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '') - env: - SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} - run: | - set -euo pipefail - - mapfile -t target_services < <(python - <<'PY' - import json - import os - - raw_plan = os.environ.get("SYNC_PLAN_JSON", "").strip() - if raw_plan: - plan = json.loads(raw_plan) - for target in plan["targets"]: - print(target["service_name"]) - raise SystemExit(0) - - raw_targets = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON", "").strip() - if raw_targets: - payload = json.loads(raw_targets) - entries = payload.get("targets") if isinstance(payload, dict) else payload - for entry in entries: - service = ( - entry.get("service") - or entry.get("service_name") - or entry.get("cloud_run_service") - ) - runtime_target = entry.get("runtime_target") or entry.get("runtime_target_json") - if not service and isinstance(runtime_target, str): - runtime_target = json.loads(runtime_target) - if not service and isinstance(runtime_target, dict): - service = runtime_target.get("service_name") - service = str(service or "").strip() - if service: - print(service) - raise SystemExit(0) - - raw_services = os.environ.get("CLOUD_RUN_SERVICES", "").strip() or os.environ.get("CLOUD_RUN_SERVICE", "").strip() - for chunk in raw_services.replace(";", ",").replace("\n", ",").split(","): - service = chunk.strip() - if service: - print(service) - PY - ) - - for cloud_run_service in "${target_services[@]}"; do - keep_revisions="$( - gcloud run services describe "${cloud_run_service}" \ - --region "${CLOUD_RUN_REGION}" \ - --format=json \ - | python -c ' - import json - import sys - service = json.load(sys.stdin) - revisions = { - item.get("revisionName") - for item in service.get("status", {}).get("traffic", []) - if item.get("revisionName") and int(item.get("percent") or 0) > 0 - } - latest_ready = service.get("status", {}).get("latestReadyRevisionName") - if latest_ready: - revisions.add(latest_ready) - for revision in sorted(revisions): - print(revision) - ' - )" - - if [ -z "${keep_revisions}" ]; then - echo "No active Cloud Run revision found for ${cloud_run_service}; skipping revision prune." >&2 - continue - fi - - mapfile -t revisions_to_delete < <( - gcloud run revisions list \ - --service "${cloud_run_service}" \ - --region "${CLOUD_RUN_REGION}" \ - --format='value(metadata.name)' \ - | grep -Fvx -f <(printf '%s\n' "${keep_revisions}") || true - ) - - for revision in "${revisions_to_delete[@]}"; do - echo "Deleting old Cloud Run revision ${revision} for ${cloud_run_service}." - gcloud run revisions delete "${revision}" \ - --region "${CLOUD_RUN_REGION}" \ - --quiet 2>&1 || true - done - done - - - name: Clean up old Cloud Run images - if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' && (env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '') - run: | - set -euo pipefail - - artifact_registry_hostname="${GCP_ARTIFACT_REGISTRY_HOSTNAME:-${CLOUD_RUN_REGION}-docker.pkg.dev}" - image_repo="${artifact_registry_hostname}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/interactivebrokersplatform/cloud-run" - old_digests="$(gcloud artifacts docker images list "${image_repo}" \ - --project="${GCP_PROJECT_ID}" \ - --include-tags \ - --format=json \ - | python -c 'import json,os,sys; keep=os.environ["SOURCE_COMMIT"]; rows=json.load(sys.stdin); print("\n".join(row["version"] for row in rows if not any(keep in tag for tag in (row.get("tags") or []))))')" - - while IFS= read -r digest; do - if [ -z "${digest}" ]; then - continue - fi - gcloud artifacts docker images delete "${image_repo}@${digest}" \ - --project="${GCP_PROJECT_ID}" \ - --delete-tags \ - --async \ - --quiet - done <<< "${old_digests}" + STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} + STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TG_TOKEN: ${{ secrets.TG_TOKEN }} diff --git a/scripts/env_sync_drift_check.py b/scripts/env_sync_drift_check.py index cba0d69c..de751273 100755 --- a/scripts/env_sync_drift_check.py +++ b/scripts/env_sync_drift_check.py @@ -2,7 +2,7 @@ """Read-only drift check: what would a full ``sync-cloud-run-env`` env sync change? Re-runs the exact "Sync Cloud Run environment" step script from -``.github/workflows/sync-cloud-run-env.yml`` (read from the checked-out ref) +``.github/workflows/sync-cloud-run-env-protected.yml`` (read from the checked-out ref) with its single mutating ``gcloud "${gcloud_args[@]}"`` call replaced by a capture, then diffs the captured env/secret/label arguments against the serving (100% traffic) Cloud Run revision. @@ -24,7 +24,7 @@ from pathlib import Path from typing import Any -WORKFLOW = Path(".github/workflows/sync-cloud-run-env.yml") +WORKFLOW = Path(".github/workflows/sync-cloud-run-env-protected.yml") STEP_NAME = "Sync Cloud Run environment" MUTATING_LINE = 'gcloud "${gcloud_args[@]}"' CAPTURE_LINE = ( diff --git a/tests/test_cloud_run_warmup_workflow.py b/tests/test_cloud_run_warmup_workflow.py index 60f5bbc6..c4c7e8cf 100644 --- a/tests/test_cloud_run_warmup_workflow.py +++ b/tests/test_cloud_run_warmup_workflow.py @@ -2,7 +2,7 @@ def test_env_sync_creates_non_trading_health_warmup_with_retries() -> None: - workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + workflow = Path(".github/workflows/sync-cloud-run-env-protected.yml").read_text(encoding="utf-8") assert 'scheduler.get("probe_time")' in workflow assert 'warmup_job_name="${cloud_run_service%-service}-warmup-scheduler"' in workflow @@ -16,7 +16,7 @@ def test_env_sync_creates_non_trading_health_warmup_with_retries() -> None: def test_main_scheduler_update_is_post_only_and_oidc_authenticated() -> None: - workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + workflow = Path(".github/workflows/sync-cloud-run-env-protected.yml").read_text(encoding="utf-8") for command in ( 'gcloud scheduler jobs update http "${job_name}"', 'gcloud scheduler jobs create http "${job_name}"', @@ -30,7 +30,7 @@ def test_main_scheduler_update_is_post_only_and_oidc_authenticated() -> None: def test_cloud_run_deploy_is_private_internal_and_serial() -> None: - workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + workflow = Path(".github/workflows/sync-cloud-run-env-protected.yml").read_text(encoding="utf-8") deploy_block = workflow.split('gcloud run deploy "${cloud_run_service}"', 1)[1] deploy_block = deploy_block.split("--quiet", 1)[0] @@ -75,7 +75,7 @@ def _resolve_sync_plan(tmp_path, monkeypatch, *, selector="service-b", target="c import sys import textwrap - workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + workflow = Path(".github/workflows/sync-cloud-run-env-protected.yml").read_text(encoding="utf-8") step = workflow.split(" - name: Resolve admissible Cloud Run targets\n", 1)[1].split(" - name:", 1)[0] script = textwrap.dedent(step.split(" run: |\n", 1)[1]) plan = {"mode": mode, "targets": [{"service_name": service, "env": {"RUNTIME_TARGET_ENABLED": "false"}} for service in (services or ["service-a", "service-b"])]} @@ -159,10 +159,11 @@ def test_single_target_sync_skips_global_cleanup_before_other_service_mutation() import subprocess import textwrap - workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") - assert " configured_service:\n" in workflow + workflow = Path(".github/workflows/sync-cloud-run-env-protected.yml").read_text(encoding="utf-8") + caller = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + assert " configured_service:\n" in caller assert "INPUT_CONFIGURED_SERVICE: ${{ inputs.configured_service }}" in workflow - assert "Exact inventory service_name required for configured target." in workflow + assert "Exact inventory service_name required for configured target." in caller scope_guard = "(env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '')" for name in ("Prune old Cloud Run revisions", "Clean up old Cloud Run images"): step = workflow.split(f" - name: {name}\n", 1)[1].split(" - name:", 1)[0] diff --git a/tests/test_env_sync_drift_check.py b/tests/test_env_sync_drift_check.py index 6b30a276..e412673c 100644 --- a/tests/test_env_sync_drift_check.py +++ b/tests/test_env_sync_drift_check.py @@ -9,8 +9,9 @@ import env_sync_drift_check as drift -SYNC = ROOT / ".github/workflows/sync-cloud-run-env.yml" -DRIFT = ROOT / ".github/workflows/env-sync-drift-check.yml" +SYNC = ROOT / ".github/workflows/sync-cloud-run-env-protected.yml" +DRIFT_CALLER = ROOT / ".github/workflows/env-sync-drift-check.yml" +DRIFT = ROOT / ".github/workflows/env-sync-drift-check-protected.yml" def _job_env(path: Path) -> dict[str, str]: @@ -42,12 +43,19 @@ def test_drift_workflow_env_mirrors_sync_workflow(): def test_drift_workflow_is_read_only(): text = DRIFT.read_text(encoding="utf-8") - assert "workflow_dispatch" in text + assert "workflow_dispatch" in DRIFT_CALLER.read_text(encoding="utf-8") + assert "workflow_call" in text for forbidden in ("services update", "update-traffic", "services replace", "add-iam-policy-binding", "secrets versions add", "scheduler jobs", "curl "): assert forbidden not in text +def test_default_workflow_reads_protected_sync_implementation(monkeypatch): + monkeypatch.chdir(ROOT) + assert drift.WORKFLOW.resolve() == SYNC.resolve() + assert drift.load_step_script() == drift.load_step_script(SYNC) + + def test_step_script_capture_replaces_only_mutation(): script = drift.load_step_script(SYNC) assert drift.MUTATING_LINE not in script diff --git a/tests/test_paper_notification_preview.py b/tests/test_paper_notification_preview.py index 7da964aa..9c44abc4 100644 --- a/tests/test_paper_notification_preview.py +++ b/tests/test_paper_notification_preview.py @@ -222,7 +222,9 @@ def test_workflow_static_safety_constraints(): def test_existing_runtime_workflows_unchanged_reference(): for name in ( "execution-report-heartbeat.yml", + "execution-report-heartbeat-protected.yml", "runtime-guard.yml", + "runtime-guard-protected.yml", "runtime-target-lifecycle.yml", ): text = (ROOT / ".github/workflows" / name).read_text(encoding="utf-8") diff --git a/tests/test_reconciliation_evidence_workflow.py b/tests/test_reconciliation_evidence_workflow.py index a14d5a8d..2cc314c7 100644 --- a/tests/test_reconciliation_evidence_workflow.py +++ b/tests/test_reconciliation_evidence_workflow.py @@ -5,7 +5,7 @@ Path(__file__).resolve().parents[1] / ".github" / "workflows" - / "collect-reconciliation-evidence.yml" + / "collect-reconciliation-evidence-protected.yml" ) diff --git a/tests/test_runtime_monitor_workflows.py b/tests/test_runtime_monitor_workflows.py index 32b5fd31..eff649e8 100644 --- a/tests/test_runtime_monitor_workflows.py +++ b/tests/test_runtime_monitor_workflows.py @@ -19,6 +19,7 @@ def test_execution_report_heartbeat_has_market_neutral_daily_schedule() -> None: assert 'cron: "20 22 * * *"' in workflow assert 'cron: "20 22 * * 1-5"' not in workflow + workflow = (ROOT / ".github/workflows/execution-report-heartbeat-protected.yml").read_text() assert "RUNTIME_HEARTBEAT_MARKET_AWARE:" in workflow assert "RUNTIME_HEARTBEAT_PUBLICATION_GRACE_MINUTES:" in workflow assert "RUNTIME_HEARTBEAT_SCHEDULER_LOCATION:" in workflow @@ -32,10 +33,10 @@ def test_execution_report_heartbeat_has_market_neutral_daily_schedule() -> None: def test_runtime_monitor_workflows_retry_gcp_authentication() -> None: workflows = { name: (ROOT / ".github/workflows" / name).read_text() - for name in ("execution-report-heartbeat.yml", "runtime-guard.yml") + for name in ("execution-report-heartbeat-protected.yml", "runtime-guard-protected.yml") } - heartbeat_job = _job_block(workflows["execution-report-heartbeat.yml"], "heartbeat") - runtime_guard = workflows["runtime-guard.yml"] + heartbeat_job = _job_block(workflows["execution-report-heartbeat-protected.yml"], "heartbeat") + runtime_guard = workflows["runtime-guard-protected.yml"] for workflow in (heartbeat_job, runtime_guard): assert workflow.count("google-github-actions/auth@v3") == 2 @@ -72,7 +73,8 @@ def test_manual_healthy_notify_is_typed_and_explicitly_opt_in() -> None: assert "type: boolean" in block assert "default: false" in block assert "required: false" in block - line = next(line for line in workflow.splitlines() if "RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS:" in line) + runtime = (ROOT / ".github/workflows/execution-report-heartbeat-protected.yml").read_text() + line = next(line for line in runtime.splitlines() if "RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS:" in line) assert "inputs.notify_on_success" in line assert "github.event.inputs" not in line assert "vars.RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS" not in line @@ -80,7 +82,7 @@ def test_manual_healthy_notify_is_typed_and_explicitly_opt_in() -> None: def test_schedule_and_manual_default_never_inherit_legacy_success_variable() -> None: - workflow = (ROOT / ".github/workflows/execution-report-heartbeat.yml").read_text() + workflow = (ROOT / ".github/workflows/execution-report-heartbeat-protected.yml").read_text() for event in ("schedule", "workflow_dispatch", "repository_dispatch"): for explicit_input in (None, False, True): for repository_value in (None, "false", "true"): @@ -93,9 +95,10 @@ def test_manual_quiet_control_preserves_existing_alert_and_report_steps() -> Non workflow = (ROOT / ".github/workflows/execution-report-heartbeat.yml").read_text() alert_block = _manual_input_block(workflow, "fail_workflow_on_alert") assert 'default: "true"' in alert_block - assert "RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }}" in workflow - assert "uv run --no-sync python scripts/execution_report_heartbeat.py" in workflow - assert "Publish read-only runtime execution evidence" in workflow + runtime = (ROOT / ".github/workflows/execution-report-heartbeat-protected.yml").read_text() + assert "RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }}" in runtime + assert "uv run --no-sync python scripts/execution_report_heartbeat.py" in runtime + assert "Publish read-only runtime execution evidence" in runtime assert 'cron: "20 22 * * *"' in workflow @@ -105,6 +108,7 @@ def test_ibkr_manual_validation_does_not_opt_into_drills_or_account_facts() -> N account_facts = _manual_input_block(workflow, "account_facts_target") assert "type: boolean" in digest and "default: false" in digest assert "default: disabled" in account_facts - assert "always() && (github.event_name == 'schedule' || inputs.send_daily_dry_run_digest)" in workflow + runtime = (ROOT / ".github/workflows/execution-report-heartbeat-protected.yml").read_text() + assert "always() && (github.event_name == 'schedule' || inputs.send_daily_dry_run_digest)" in runtime assert "inputs.account_facts_target == 'primary-live'" in workflow assert "inputs.account_facts_target == 'additional-1'" in workflow diff --git a/tests/test_safe_no_traffic_deploy_isolation.py b/tests/test_safe_no_traffic_deploy_isolation.py index 09031084..2d2e2dc9 100644 --- a/tests/test_safe_no_traffic_deploy_isolation.py +++ b/tests/test_safe_no_traffic_deploy_isolation.py @@ -6,7 +6,8 @@ ROOT = Path(__file__).resolve().parents[1] -WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env.yml" +CALLER = ROOT / ".github" / "workflows" / "sync-cloud-run-env.yml" +WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env-protected.yml" READBACK = ROOT / "scripts" / "verify_cloud_run_no_traffic_deploy.py" @@ -33,12 +34,13 @@ def test_image_deploy_is_no_traffic_only() -> None: def test_env_sync_does_not_imply_traffic_or_scheduler() -> None: workflow = _workflow() - assert " approve_traffic_shift:" in workflow - assert " approve_scheduler_sync:" in workflow - assert ' default: false\n' in workflow.split(" approve_traffic_shift:", 1)[1].split( + caller = CALLER.read_text(encoding="utf-8") + assert " approve_traffic_shift:" in caller + assert " approve_scheduler_sync:" in caller + assert ' default: false\n' in caller.split(" approve_traffic_shift:", 1)[1].split( " approve_scheduler_sync:", 1 )[0] - assert ' default: false\n' in workflow.split(" approve_scheduler_sync:", 1)[1].split( + assert ' default: false\n' in caller.split(" approve_scheduler_sync:", 1)[1].split( "\nenv:", 1 )[0] @@ -80,7 +82,8 @@ def test_configured_requires_exact_service_before_mutations() -> None: assert 'configured_service requires an exact private inventory service_name' in workflow # Blank configured_service must not silently expand to every inventory target. assert 'blank keeps all configured targets' not in workflow.lower() - assert 'description: "Exact inventory service_name required for configured target."' in workflow + caller = CALLER.read_text(encoding="utf-8") + assert 'description: "Exact inventory service_name required for configured target."' in caller def test_no_traffic_deploy_has_capture_verify_and_fail_closed_script() -> None: diff --git a/tests/test_scheduler_deadline_contract.py b/tests/test_scheduler_deadline_contract.py index 2f7f1c25..3fd24d38 100644 --- a/tests/test_scheduler_deadline_contract.py +++ b/tests/test_scheduler_deadline_contract.py @@ -7,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] -WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env.yml" +WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env-protected.yml" MAIN = ROOT / "main.py" diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 126dde91..13c1bcf3 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -2,13 +2,14 @@ set -euo pipefail repo_dir="$(cd "$(dirname "$0")/.." && pwd)" -workflow_file="$repo_dir/.github/workflows/sync-cloud-run-env.yml" +caller_file="$repo_dir/.github/workflows/sync-cloud-run-env.yml" +workflow_file="$repo_dir/.github/workflows/sync-cloud-run-env-protected.yml" -if grep -Fq 'workflow_run:' "$workflow_file"; then +if grep -Fq 'workflow_run:' "$caller_file"; then echo "deploy workflow must require a manual dispatch" >&2 exit 1 fi -grep -Fq "if: github.event_name == 'workflow_dispatch'" "$workflow_file" +grep -Fq "if: github.event_name == 'workflow_dispatch'" "$caller_file" grep -Fq 'GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main' "$workflow_file" grep -Fq 'GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com' "$workflow_file" @@ -27,8 +28,8 @@ grep -Fq 'gcloud storage cp --quiet "${RECOVERY_STATE_LEDGER_URI}" "${recovery_s grep -Fq 'echo "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PATH=${recovery_state_ledger_path}" >> "$GITHUB_ENV"' "$workflow_file" grep -Fq 'ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }}' "$workflow_file" -grep -Fq 'target:' "$workflow_file" -grep -Fq 'default: hk-verify' "$workflow_file" +grep -Fq 'target:' "$caller_file" +grep -Fq 'default: hk-verify' "$caller_file" grep -Fq 'INPUT_DEPLOY_IMAGE: ${{ inputs.deploy_image }}' "$workflow_file" grep -Fq '[ "${INPUT_DEPLOY_IMAGE:-true}" = "true" ]; }; then' "$workflow_file" grep -Fq '[ "${INPUT_SYNC_ENV:-true}" = "true" ]; }; then' "$workflow_file" @@ -42,13 +43,13 @@ grep -Fq 'echo "IBKR_MARKET_EXCHANGE=SEHK"' "$workflow_file" grep -Fq 'echo "CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT=false" >> "$GITHUB_ENV"' "$workflow_file" grep -Fq 'CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }}' "$workflow_file" grep -Fq 'CLOUD_RUN_SERVICES: ${{ secrets.CLOUD_RUN_SERVICES }}' "$workflow_file" -grep -Fq 'CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }}' "$workflow_file" +grep -Fq 'CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }}' "$caller_file" grep -Fq 'CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }}' "$workflow_file" grep -Fq 'CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }}' "$workflow_file" grep -Fq 'CLOUD_SCHEDULER_MAIN_TIME: ${{ vars.CLOUD_SCHEDULER_MAIN_TIME }}' "$workflow_file" grep -Fq 'CLOUD_SCHEDULER_PROBE_TIME: ${{ vars.CLOUD_SCHEDULER_PROBE_TIME }}' "$workflow_file" grep -Fq 'CLOUD_SCHEDULER_PRECHECK_TIME: ${{ vars.CLOUD_SCHEDULER_PRECHECK_TIME }}' "$workflow_file" -grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }}' "$workflow_file" +grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }}' "$caller_file" grep -Fq 'ACCOUNT_GROUP: ${{ vars.ACCOUNT_GROUP }}' "$workflow_file" grep -Fq 'IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME: ${{ vars.IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME }}' "$workflow_file" grep -Fq 'IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }}' "$workflow_file" @@ -93,7 +94,7 @@ grep -Fq 'Cloud Run env sync did not resolve any targets' "$workflow_file" grep -Fq 'TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN' "$workflow_file" grep -Fq 'Wait for Cloud Run deployment of current commit' "$workflow_file" -grep -Fq 'target_sha="${GITHUB_SHA}"' "$workflow_file" +grep -Fq 'target_sha="${SOURCE_COMMIT}"' "$workflow_file" grep -Fq 'Skipping Cloud Run commit wait because CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT is disabled.' "$workflow_file" grep -Fq 'for target in plan["targets"]:' "$workflow_file" grep -Fq "gcloud run services describe \"\${cloud_run_service}\" --region \"\${CLOUD_RUN_REGION}\" --format='value(spec.template.metadata.labels.commit-sha)'" "$workflow_file" diff --git a/tests/test_sync_cloud_run_source_commit.py b/tests/test_sync_cloud_run_source_commit.py index aa9f02a5..4dd71522 100644 --- a/tests/test_sync_cloud_run_source_commit.py +++ b/tests/test_sync_cloud_run_source_commit.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] -WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env.yml" +WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env-protected.yml" def _source_validation_script() -> str: diff --git a/tests/test_uv_dependency_workflow.py b/tests/test_uv_dependency_workflow.py index d4dfd26d..4b5e430c 100644 --- a/tests/test_uv_dependency_workflow.py +++ b/tests/test_uv_dependency_workflow.py @@ -27,15 +27,18 @@ def test_pyproject_declares_runtime_and_test_dependencies() -> None: def test_ci_docker_and_runtime_monitoring_use_uv_lock() -> None: ci = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") dockerfile = Path("Dockerfile").read_text(encoding="utf-8") - env_sync = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") - runtime_guard = Path(".github/workflows/runtime-guard.yml").read_text(encoding="utf-8") + env_sync = Path(".github/workflows/sync-cloud-run-env-protected.yml").read_text(encoding="utf-8") + runtime_guard = Path(".github/workflows/runtime-guard-protected.yml").read_text(encoding="utf-8") runtime_target_lifecycle = Path(".github/workflows/runtime-target-lifecycle.yml").read_text( encoding="utf-8" ) execution_report_heartbeat = Path(".github/workflows/execution-report-heartbeat.yml").read_text( encoding="utf-8" ) - heartbeat_job = _job_block(execution_report_heartbeat, "heartbeat") + protected_heartbeat = Path(".github/workflows/execution-report-heartbeat-protected.yml").read_text( + encoding="utf-8" + ) + heartbeat_job = _job_block(protected_heartbeat, "heartbeat") publisher_job = _job_block(execution_report_heartbeat, "account-facts-publisher") lockfile = Path("uv.lock").read_text(encoding="utf-8") @@ -56,7 +59,7 @@ def test_ci_docker_and_runtime_monitoring_use_uv_lock() -> None: assert "uv run --no-sync python scripts/cloud_run_runtime_guard.py" in runtime_guard assert "uv run --no-sync python scripts/cloud_run_runtime_guard.py" in runtime_target_lifecycle assert "uv run --no-sync python scripts/execution_report_heartbeat.py" in runtime_target_lifecycle - assert "uv run --no-sync python scripts/execution_report_heartbeat.py" in execution_report_heartbeat + assert "uv run --no-sync python scripts/execution_report_heartbeat.py" in heartbeat_job assert "uv run --no-sync python scripts/publish_account_facts_from_report.py" in publisher_job assert publisher_job.count(setup_uv) == 1 assert publisher_job.count("uv sync --frozen --no-dev") == 1 @@ -68,6 +71,7 @@ def test_ci_docker_and_runtime_monitoring_use_uv_lock() -> None: assert 'name = "pandas-market-calendars"' in lockfile assert 'pandas-market-calendars==5.4.0' not in runtime_target_lifecycle assert 'pandas-market-calendars==5.4.0' not in execution_report_heartbeat + assert 'pandas-market-calendars==5.4.0' not in heartbeat_job assert "Traceback|ImportError|ModuleNotFoundError" in runtime_target_lifecycle assert "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_URI" in env_sync assert "Fetch opt-in immutable recovery state ledger" in env_sync diff --git a/tests/test_verify_image_ues_release_binding.py b/tests/test_verify_image_ues_release_binding.py index d90d6467..7eef7ba1 100644 --- a/tests/test_verify_image_ues_release_binding.py +++ b/tests/test_verify_image_ues_release_binding.py @@ -79,7 +79,7 @@ def test_main_exit_codes(monkeypatch, capsys): def test_deploy_workflow_runs_guard_before_push_and_deploy(): - workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + workflow = Path(".github/workflows/sync-cloud-run-env-protected.yml").read_text(encoding="utf-8") guard_at = workflow.index("scripts/verify_image_ues_release_binding.py --image") assert workflow.index("docker build --pull") < guard_at < workflow.index('docker push "${image}"') assert guard_at < workflow.index("gcloud run deploy") diff --git a/tests/test_workflow_json_secret_boundary.py b/tests/test_workflow_json_secret_boundary.py new file mode 100644 index 00000000..8216e657 --- /dev/null +++ b/tests/test_workflow_json_secret_boundary.py @@ -0,0 +1,151 @@ +"""Offline structural checks; never load runtime configuration or execute workflows.""" +import re +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] / ".github/workflows" +PROTECTED = ("CLOUD_RUN_SERVICE_TARGETS_JSON", "RUNTIME_TARGET_JSON") +WORKFLOWS = ( + "runtime-guard", "execution-report-heartbeat", "sync-cloud-run-env", + "env-sync-drift-check", "notify-route-check", "collect-reconciliation-evidence", +) + + +def _block(text: str, key: str, indent: int) -> str: + match = re.search(rf"(?m)^{' ' * indent}{re.escape(key)}:\s*\n", text) + assert match is not None, key + tail = text[match.end():] + end = re.search(rf"(?m)^\S|^ {{1,{indent}}}\S" if indent else r"(?m)^\S", tail) + return tail[:end.start()] if end else tail + + +def _jobs(text: str) -> dict[str, str]: + jobs = _block(text, "jobs", 0) + return {key: _block(jobs, key, 2) for key in re.findall(r"(?m)^ ([\w-]+):\s*$", jobs)} + + +def _unsafe_json_env(text: str) -> list[str]: + """Inspect actual env mappings, excluding run blocks and reusable secrets maps.""" + unsafe = [] + env_indent = None + literal_indent = None + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip()) + if literal_indent is not None: + if indent > literal_indent: + continue + literal_indent = None + if env_indent is not None and indent <= env_indent: + env_indent = None + if re.search(r":\s*[|>][-+]?\s*$", line): + literal_indent = indent + if line.strip() == "env:": + env_indent = indent + elif env_indent is not None and any(f"vars.{key}" in line for key in PROTECTED): + unsafe.append(line.strip()) + return unsafe + + +@pytest.mark.parametrize("name", WORKFLOWS) +def test_json_crosses_secret_boundary_before_any_execution(name: str) -> None: + caller = (ROOT / f"{name}.yml").read_text() + callee = (ROOT / f"{name}-protected.yml").read_text() + calls = [job for job in _jobs(caller).values() + if f"uses: ./.github/workflows/{name}-protected.yml" in job] + assert len(calls) == 1 + call = calls[0] + assert "steps:" not in call and "runs-on:" not in call and " env:" not in call + assert "workflow_call:" in callee + assert "workflow_dispatch:" not in callee and "schedule:" not in callee + # Only the original entry point owns run concurrency; no nested self-cancellation. + assert "concurrency:" not in callee + declared = _block(callee, "secrets", 4) + passed = _block(call, "secrets", 4) + referenced = set(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", _block(callee, "jobs", 0))) + assert referenced + for key in referenced: + assert f" {key}:\n required: false" in declared + value = f"vars.{key} || secrets.{key}" if key in PROTECTED else f"secrets.{key}" + assert f" {key}: ${{{{ {value} }}}}" in passed + for key in PROTECTED: + assert f"vars.{key}" not in callee + if key in referenced: + assert f"{key}: ${{{{ secrets.{key} }}}}" in callee + assert not _unsafe_json_env(caller) + assert not _unsafe_json_env(callee) + + +def test_all_workflows_reject_variable_backed_json_in_env() -> None: + for path in ROOT.glob("*.yml"): + assert not _unsafe_json_env(path.read_text()), path.name + + +@pytest.mark.parametrize("indent", (0, 4, 8)) +def test_detector_catches_workflow_job_and_step_env_regressions(indent: int) -> None: + fixture = " " * indent + "env:\n" + " " * (indent + 2) + fixture += "RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }}\n" + assert len(_unsafe_json_env(fixture)) == 1 + + +def test_detector_accepts_secret_boundary_and_ignores_literal_script_text() -> None: + fixture = """jobs: + caller: + uses: ./.github/workflows/synthetic.yml + secrets: + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} + executor: + env: + RUNTIME_TARGET_JSON: ${{ secrets.RUNTIME_TARGET_JSON }} + steps: + - run: | + env: + RUNTIME_TARGET_JSON: vars.RUNTIME_TARGET_JSON +""" + assert not _unsafe_json_env(fixture) + + +def test_reconciliation_keeps_both_inventory_consumers_behind_one_boundary() -> None: + callee = (ROOT / "collect-reconciliation-evidence-protected.yml").read_text() + jobs = _jobs(callee) + assert set(jobs) == {"resolve-matrix", "collect"} + assert "needs: resolve-matrix" in jobs["collect"] + assert "matrix: ${{ fromJSON(needs.resolve-matrix.outputs.matrix) }}" in jobs["collect"] + for job in jobs.values(): + assert "CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }}" in job + # The event identity remains the original caller, including provenance validation. + assert '.github/workflows/collect-reconciliation-evidence.yml@refs/heads/main' in jobs['collect'] + + +def test_existing_digest_secret_boundary_is_preserved() -> None: + emit = (ROOT / "digest-candidates-emit.yml").read_text() + for key in PROTECTED: + assert f"vars.{key}" not in emit + assert f"{key}: ${{{{ secrets.{key} }}}}" in emit + for name in ("digest-candidates-daily", "execution-report-heartbeat"): + caller = (ROOT / f"{name}.yml").read_text() + assert "uses: ./.github/workflows/digest-candidates-emit.yml" in caller + + +@pytest.mark.parametrize("name", WORKFLOWS) +def test_reusable_inputs_are_forwarded_without_changing_types_or_values(name: str) -> None: + caller = (ROOT / f"{name}.yml").read_text() + callee = (ROOT / f"{name}-protected.yml").read_text() + call = next(job for job in _jobs(caller).values() + if f"uses: ./.github/workflows/{name}-protected.yml" in job) + referenced = set(re.findall(r"inputs\.([a-z_]+)", _block(callee, "jobs", 0))) + if not referenced: + assert " with:" not in call + return + manual = _block(_block(caller, "workflow_dispatch", 2), "inputs", 4) + declared = _block(_block(callee, "workflow_call", 2), "inputs", 4) + for key in referenced: + assert f" {key}: ${{{{ inputs.{key} }}}}" in _block(call, "with", 4) + source_type = re.search(r"type: (\w+)", _block(manual, key, 6)).group(1) + expected_type = "string" if source_type == "choice" else source_type + assert f"type: {expected_type}" in _block(declared, key, 6) + # Dispatch defaults are applied by the original entry point, not imposed on schedules. + assert "default:" not in _block(declared, key, 6)