Skip to content

Deploy Cloud Run

Deploy Cloud Run #340

name: Deploy Cloud Run
on:
workflow_dispatch:
inputs:
expected_sha:
description: "Full commit SHA to deploy; must match the dispatched and approved branch head"
required: true
type: string
approved_ref:
description: "Fully qualified approved branch ref, for example refs/heads/main"
required: true
type: string
allow_configuration_sync:
description: "Also permit explicit env, secret, Scheduler, and IAM synchronization"
required: false
default: false
type: boolean
allow_traffic_promotion:
description: "Also permit explicit Cloud Run traffic promotion after configuration sync"
required: false
default: false
type: boolean
allow_cleanup:
description: "Also permit explicit revision and image cleanup"
required: false
default: false
type: boolean
stage_cached_balance_diagnostic:
description: "Stage the fixed cached-balance diagnostic image on a zero-traffic tagged revision only"
required: false
default: false
type: boolean
permissions:
contents: read
env:
GCP_PROJECT_ID: firstradequant
GCP_PROJECT_NUMBER: "1088907247379"
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/1088907247379/locations/global/workloadIdentityPools/github-actions/providers/github-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: firstrade-platform-deploy@firstradequant.iam.gserviceaccount.com
GCP_RUNTIME_SERVICE_ACCOUNT: firstrade-platform-runtime@firstradequant.iam.gserviceaccount.com
GCP_SCHEDULER_SERVICE_ACCOUNT: firstrade-platform-scheduler@firstradequant.iam.gserviceaccount.com
GCP_ARTIFACT_REGISTRY_HOSTNAME: us-central1-docker.pkg.dev
GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
stage-cached-balance-diagnostic:
name: Stage cached-balance diagnostic (no traffic)
if: github.event_name == 'workflow_dispatch' && inputs.stage_cached_balance_diagnostic == true
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
id-token: write
env:
ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }}
CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}
CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }}
GCP_PROJECT_ID: firstradequant
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/1088907247379/locations/global/workloadIdentityPools/github-actions/providers/github-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: firstrade-platform-deploy@firstradequant.iam.gserviceaccount.com
GCP_ARTIFACT_REGISTRY_HOSTNAME: us-central1-docker.pkg.dev
GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy
CACHED_DIAGNOSTIC_SOURCE_SHA: 3317c0282ca5e70a55b97084572e5013a8eeae3f
EXPECTED_SERVING_SOURCE_SHA: e0043ca860a36c1790ddbb866cb848e298a3d3c7
CACHED_DIAGNOSTIC_TAG: cached-balance-diagnostic
steps:
- name: Validate isolated stage inputs
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
APPROVED_REF: ${{ inputs.approved_ref }}
DISPATCH_SHA: ${{ github.sha }}
ALLOW_CONFIGURATION_SYNC: ${{ inputs.allow_configuration_sync }}
ALLOW_TRAFFIC_PROMOTION: ${{ inputs.allow_traffic_promotion }}
ALLOW_CLEANUP: ${{ inputs.allow_cleanup }}
run: |
set -euo pipefail
if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" != "true" ]; then
echo "Cached-balance diagnostic staging is disabled." >&2
exit 1
fi
if [ -z "${CLOUD_RUN_REGION:-}" ] || [ -z "${CLOUD_RUN_SERVICE:-}" ]; then
echo "Cached-balance diagnostic staging is missing its fixed service target." >&2
exit 1
fi
if [ "${ALLOW_CONFIGURATION_SYNC,,}" = "true" ] \
|| [ "${ALLOW_TRAFFIC_PROMOTION,,}" = "true" ] \
|| [ "${ALLOW_CLEANUP,,}" = "true" ]; then
echo "Stage-only mode cannot be combined with configuration, traffic, or cleanup actions." >&2
exit 1
fi
if ! [[ "${EXPECTED_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full lowercase commit SHA." >&2
exit 1
fi
case "${APPROVED_REF}" in refs/heads/*) ;; *) echo "approved_ref must be a branch ref." >&2; exit 1 ;; esac
if [ "${DISPATCH_SHA}" != "${EXPECTED_SHA}" ]; then
echo "The dispatched workflow commit must match expected_sha." >&2
exit 1
fi
- name: Checkout approved stage workflow source
uses: actions/checkout@v6
with:
ref: ${{ inputs.expected_sha }}
- name: Verify approved stage workflow source
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
APPROVED_REF: ${{ inputs.approved_ref }}
run: |
set -euo pipefail
checked_out_sha="$(git rev-parse HEAD)"
approved_sha="$(git ls-remote --exit-code origin "${APPROVED_REF}" | awk 'NR == 1 { print $1 }')"
if [ "${checked_out_sha}" != "${EXPECTED_SHA}" ] || [ "${approved_sha}" != "${EXPECTED_SHA}" ]; then
echo "The checked-out workflow and approved ref must match expected_sha." >&2
exit 1
fi
- name: Checkout fixed cached-diagnostic candidate
uses: actions/checkout@v6
with:
ref: 3317c0282ca5e70a55b97084572e5013a8eeae3f
path: cached-diagnostic-source
- name: Verify fixed candidate source
working-directory: cached-diagnostic-source
run: |
set -euo pipefail
if [ "$(git rev-parse HEAD)" != "${CACHED_DIAGNOSTIC_SOURCE_SHA}" ]; then
echo "The cached-diagnostic source does not match the fixed candidate." >&2
exit 1
fi
if ! grep -Fq 'FIRSTRADE_CACHED_BALANCE_DIAGNOSTIC_ON_HTTP=true' Dockerfile; then
echo "The fixed candidate image is missing its diagnostic-only gate default." >&2
exit 1
fi
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
version: ">= 416.0.0"
- name: Capture protected service baseline
id: stage_baseline
env:
STATE_PATH: ${{ runner.temp }}/cached-balance-stage-state.json
CONTROL_STATE_PATH: ${{ runner.temp }}/cached-balance-stage-controls.sha256
SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION }}
EXPECTED_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }}
EXPECTED_SOURCE_SHA: e0043ca860a36c1790ddbb866cb848e298a3d3c7
run: |
set -euo pipefail
service_json="${RUNNER_TEMP}/cached-balance-stage-service.json"
revision_json="${RUNNER_TEMP}/cached-balance-stage-revision.json"
if ! gcloud run services describe "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format=json \
> "${service_json}" 2>"${RUNNER_TEMP}/cached-balance-stage-read.err"; then
echo "Unable to read the current Cloud Run service configuration." >&2
exit 1
fi
active_revision="$(python3 scripts/verify_cached_diagnostic_stage.py active-revision < "${service_json}")"
if [ -z "${active_revision}" ]; then
echo "Unable to identify the currently serving immutable revision." >&2
exit 1
fi
if ! gcloud run revisions describe "${active_revision}" \
--project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format=json \
> "${revision_json}" 2>"${RUNNER_TEMP}/cached-balance-stage-revision-read.err"; then
echo "Unable to read back the currently serving immutable revision." >&2
exit 1
fi
chmod 600 "${service_json}" "${revision_json}"
if ! python3 scripts/verify_cached_diagnostic_stage.py capture \
--state "${STATE_PATH}" --revision "${revision_json}" \
--expected-service "${EXPECTED_SERVICE}" --expected-source-sha "${EXPECTED_SOURCE_SHA}" \
< "${service_json}" >/dev/null; then
exit 1
fi
if ! gcloud run services get-iam-policy "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format=json \
2>"${RUNNER_TEMP}/cached-balance-stage-iam.err" \
| python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin),sort_keys=True,separators=(",",":")))' \
| sha256sum | awk '{print $1}' > "${RUNNER_TEMP}/cached-balance-stage-iam.sha256"; then
echo "Unable to read the current Cloud Run IAM policy." >&2
exit 1
fi
if ! gcloud scheduler jobs list --project="${GCP_PROJECT_ID}" --location="${SCHEDULER_LOCATION}" \
--format=json \
2>"${RUNNER_TEMP}/cached-balance-stage-scheduler.err" \
| python3 scripts/verify_cached_diagnostic_stage.py scheduler-hash \
> "${RUNNER_TEMP}/cached-balance-stage-scheduler.sha256"; then
echo "Unable to read the current Cloud Scheduler configuration." >&2
exit 1
fi
cat "${RUNNER_TEMP}/cached-balance-stage-iam.sha256" \
"${RUNNER_TEMP}/cached-balance-stage-scheduler.sha256" > "${CONTROL_STATE_PATH}"
chmod 600 "${STATE_PATH}" "${CONTROL_STATE_PATH}"
echo "state_path=${STATE_PATH}" >> "${GITHUB_OUTPUT}"
- name: Build and push the fixed diagnostic image
id: diagnostic_image
env:
CANDIDATE_PATH: ${{ github.workspace }}/cached-diagnostic-source
run: |
set -euo pipefail
image_repo="${GCP_ARTIFACT_REGISTRY_HOSTNAME}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/firstradeplatform/${CLOUD_RUN_SERVICE}"
image="${image_repo}:cached-diagnostic-${CACHED_DIAGNOSTIC_SOURCE_SHA}"
gcloud auth configure-docker "${GCP_ARTIFACT_REGISTRY_HOSTNAME}" --quiet >/dev/null
if ! docker build --pull --tag="${image}" "${CANDIDATE_PATH}" \
>"${RUNNER_TEMP}/cached-balance-image-build.out" 2>&1; then
echo "The fixed cached-balance diagnostic image could not be built." >&2
exit 1
fi
if ! docker push "${image}" \
>"${RUNNER_TEMP}/cached-balance-image-push.out" 2>&1; then
echo "The fixed cached-balance diagnostic image could not be pushed." >&2
exit 1
fi
if ! image_digest="$(gcloud artifacts docker images describe "${image}" --project="${GCP_PROJECT_ID}" --format='value(image_summary.digest)' 2>"${RUNNER_TEMP}/cached-balance-image-read.err")"; then
echo "Unable to read back the staged image digest." >&2
exit 1
fi
if ! [[ "${image_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "The staged image digest has an invalid format." >&2
exit 1
fi
echo "image_digest=${image_digest}" >> "${GITHUB_OUTPUT}"
- name: Stage the image on a zero-traffic tagged revision
env:
IMAGE_DIGEST: ${{ steps.diagnostic_image.outputs.image_digest }}
run: |
set -euo pipefail
image_repo="${GCP_ARTIFACT_REGISTRY_HOSTNAME}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/firstradeplatform/${CLOUD_RUN_SERVICE}"
image_ref="${image_repo}@${IMAGE_DIGEST}"
if ! gcloud run deploy "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" \
--image="${image_ref}" --no-traffic --tag="${CACHED_DIAGNOSTIC_TAG}" --quiet \
>"${RUNNER_TEMP}/cached-balance-stage-deploy.out" \
2>"${RUNNER_TEMP}/cached-balance-stage-deploy.err"; then
echo "The cached-balance diagnostic revision could not be staged." >&2
exit 1
fi
- name: Verify stage readback and unchanged controls
env:
STATE_PATH: ${{ steps.stage_baseline.outputs.state_path }}
CONTROL_STATE_PATH: ${{ runner.temp }}/cached-balance-stage-controls.sha256
SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION || vars.CLOUD_RUN_REGION }}
IMAGE_DIGEST: ${{ steps.diagnostic_image.outputs.image_digest }}
run: |
set -euo pipefail
image_repo="${GCP_ARTIFACT_REGISTRY_HOSTNAME}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/firstradeplatform/${CLOUD_RUN_SERVICE}"
image_ref="${image_repo}@${IMAGE_DIGEST}"
service_json="${RUNNER_TEMP}/cached-balance-stage-after.json"
if ! gcloud run services describe "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format=json \
> "${service_json}" 2>"${RUNNER_TEMP}/cached-balance-stage-after-read.err"; then
echo "Unable to read back the staged Cloud Run service." >&2
exit 1
fi
python3 scripts/verify_cached_diagnostic_stage.py verify \
--state "${STATE_PATH}" --expected-image "${image_ref}" < "${service_json}"
current_controls="${RUNNER_TEMP}/cached-balance-stage-controls-after.sha256"
if ! gcloud run services get-iam-policy "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format=json \
2>"${RUNNER_TEMP}/cached-balance-stage-after-iam.err" \
| python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin),sort_keys=True,separators=(",",":")))' \
| sha256sum | awk '{print $1}' > "${RUNNER_TEMP}/cached-balance-stage-after-iam.sha256"; then
echo "Unable to verify the Cloud Run IAM policy readback." >&2
exit 1
fi
if ! gcloud scheduler jobs list --project="${GCP_PROJECT_ID}" --location="${SCHEDULER_LOCATION}" \
--format=json \
2>"${RUNNER_TEMP}/cached-balance-stage-after-scheduler.err" \
| python3 scripts/verify_cached_diagnostic_stage.py scheduler-hash \
> "${RUNNER_TEMP}/cached-balance-stage-after-scheduler.sha256"; then
echo "Unable to verify the Cloud Scheduler readback." >&2
exit 1
fi
cat "${RUNNER_TEMP}/cached-balance-stage-after-iam.sha256" \
"${RUNNER_TEMP}/cached-balance-stage-after-scheduler.sha256" > "${current_controls}"
if ! cmp -s "${CONTROL_STATE_PATH}" "${current_controls}"; then
echo "IAM or Scheduler configuration changed during diagnostic staging." >&2
exit 1
fi
deploy-cloud-run:
name: Deploy Cloud Run
# Production deployment and environment sync require an explicit operator dispatch.
if: github.event_name == 'workflow_dispatch' && inputs.stage_cached_balance_diagnostic != true
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 }}
CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }}
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_REGION: ${{ vars.CLOUD_RUN_REGION }}
CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }}
# 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_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 }}
FIRSTRADE_USERNAME_SECRET_NAME: ${{ vars.FIRSTRADE_USERNAME_SECRET_NAME }}
FIRSTRADE_PASSWORD_SECRET_NAME: ${{ vars.FIRSTRADE_PASSWORD_SECRET_NAME }}
FIRSTRADE_MFA_SECRET_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_SECRET_SECRET_NAME }}
FIRSTRADE_PIN_SECRET_NAME: ${{ vars.FIRSTRADE_PIN_SECRET_NAME }}
FIRSTRADE_MFA_EMAIL_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_EMAIL_SECRET_NAME }}
FIRSTRADE_MFA_PHONE_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_PHONE_SECRET_NAME }}
FIRSTRADE_MFA_CODE_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_CODE_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_PREFIX: ${{ vars.ACCOUNT_PREFIX }}
ACCOUNT_REGION: ${{ vars.ACCOUNT_REGION }}
FIRSTRADE_ACCOUNT: ${{ vars.FIRSTRADE_ACCOUNT }}
FIRSTRADE_COOKIE_DIR: ${{ vars.FIRSTRADE_COOKIE_DIR }}
FIRSTRADE_DRY_RUN_ONLY: ${{ vars.FIRSTRADE_DRY_RUN_ONLY }}
FIRSTRADE_REUSE_SESSION: ${{ vars.FIRSTRADE_REUSE_SESSION }}
FIRSTRADE_SESSION_CACHE_TTL_SECONDS: ${{ vars.FIRSTRADE_SESSION_CACHE_TTL_SECONDS }}
FIRSTRADE_ENABLE_LIVE_TRADING: ${{ vars.FIRSTRADE_ENABLE_LIVE_TRADING }}
FIRSTRADE_RUN_SMOKE_ON_HTTP: ${{ vars.FIRSTRADE_RUN_SMOKE_ON_HTTP }}
FIRSTRADE_RUN_STRATEGY_ON_HTTP: ${{ vars.FIRSTRADE_RUN_STRATEGY_ON_HTTP }}
FIRSTRADE_LIVE_ORDER_ACK: ${{ vars.FIRSTRADE_LIVE_ORDER_ACK }}
FIRSTRADE_MAX_ORDER_NOTIONAL_USD: ${{ vars.FIRSTRADE_MAX_ORDER_NOTIONAL_USD }}
FIRSTRADE_MIN_RESERVED_CASH_USD: ${{ vars.FIRSTRADE_MIN_RESERVED_CASH_USD }}
FIRSTRADE_RESERVED_CASH_RATIO: ${{ vars.FIRSTRADE_RESERVED_CASH_RATIO }}
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}
FIRSTRADE_SMOKE_SYMBOL: ${{ vars.FIRSTRADE_SMOKE_SYMBOL }}
FIRSTRADE_FEATURE_SNAPSHOT_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_PATH }}
FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH }}
FIRSTRADE_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_FALLBACK_MODE }}
FIRSTRADE_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }}
FIRSTRADE_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_MAX_STALE_DAYS }}
FIRSTRADE_GCS_STATE_BUCKET: ${{ vars.FIRSTRADE_GCS_STATE_BUCKET }}
FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT: ${{ vars.FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT }}
FIRSTRADE_PERSIST_STRATEGY_RUNS: ${{ vars.FIRSTRADE_PERSIST_STRATEGY_RUNS }}
FIRSTRADE_PERSIST_SESSION_CACHE: ${{ vars.FIRSTRADE_PERSIST_SESSION_CACHE }}
FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP: ${{ vars.FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP }}
FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS: ${{ vars.FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS }}
FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }}
FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }}
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }}
FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI }}
FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }}
FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }}
FIRSTRADE_MARKET_SIGNAL_CACHE_DIR: ${{ vars.FIRSTRADE_MARKET_SIGNAL_CACHE_DIR }}
FIRSTRADE_MARKET_SIGNAL_REQUIRED: ${{ vars.FIRSTRADE_MARKET_SIGNAL_REQUIRED }}
FIRSTRADE_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.FIRSTRADE_MARKET_SIGNAL_FALLBACK_MODE }}
FIRSTRADE_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.FIRSTRADE_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 }}
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
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 }}
FIRSTRADE_CASH_ONLY_EXECUTION: ${{ vars.FIRSTRADE_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 }}
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_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 }}
FIRSTRADE_USERNAME: ${{ secrets.FIRSTRADE_USERNAME }}
FIRSTRADE_PASSWORD: ${{ secrets.FIRSTRADE_PASSWORD }}
FIRSTRADE_MFA_SECRET: ${{ secrets.FIRSTRADE_MFA_SECRET }}
FIRSTRADE_PIN: ${{ secrets.FIRSTRADE_PIN }}
FIRSTRADE_MFA_EMAIL: ${{ secrets.FIRSTRADE_MFA_EMAIL }}
FIRSTRADE_MFA_PHONE: ${{ secrets.FIRSTRADE_MFA_PHONE }}
FIRSTRADE_MFA_CODE: ${{ secrets.FIRSTRADE_MFA_CODE }}
steps:
- name: Check whether deploy is enabled
id: deploy_config
run: |
set -euo pipefail
# 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
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run deploy on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2
exit 0
fi
if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run deploy because ENABLE_GITHUB_CLOUD_RUN_DEPLOY is not true." >&2
exit 0
fi
echo "enabled=true" >> "$GITHUB_OUTPUT"
- name: Checkout repository
if: steps.deploy_config.outputs.enabled == 'true'
uses: actions/checkout@v6
with:
ref: ${{ inputs.expected_sha }}
- name: Validate deploy inputs
if: steps.deploy_config.outputs.enabled == 'true'
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
APPROVED_REF: ${{ inputs.approved_ref }}
DISPATCH_SHA: ${{ github.sha }}
run: |
set -euo pipefail
missing_vars=()
for var_name in CLOUD_RUN_REGION CLOUD_RUN_SERVICE EXPECTED_SHA APPROVED_REF; do
if [ -z "${!var_name:-}" ]; then
missing_vars+=("${var_name}")
fi
done
if [ "${#missing_vars[@]}" -gt 0 ]; then
echo "Cloud Run deploy is enabled, but required values are missing:" >&2
printf ' - %s\n' "${missing_vars[@]}" >&2
exit 1
fi
if ! [[ "${EXPECTED_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full lowercase commit SHA." >&2
exit 1
fi
case "${APPROVED_REF}" in
refs/heads/*) ;;
*)
echo "approved_ref must be a fully qualified branch ref." >&2
exit 1
;;
esac
checked_out_sha="$(git rev-parse HEAD)"
approved_sha="$(git ls-remote --exit-code origin "${APPROVED_REF}" | awk 'NR == 1 { print $1 }')"
if [ "${DISPATCH_SHA}" != "${EXPECTED_SHA}" ] \
|| [ "${checked_out_sha}" != "${EXPECTED_SHA}" ] \
|| [ "${approved_sha}" != "${EXPECTED_SHA}" ]; then
echo "The dispatched commit, checked-out commit, and approved ref must all match expected_sha." >&2
exit 1
fi
- name: Authenticate to Google Cloud
id: auth
if: steps.deploy_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.deploy_config.outputs.enabled == 'true'
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
version: ">= 416.0.0"
- name: Capture read-only deployment baseline
id: deployment_baseline
if: steps.deploy_config.outputs.enabled == 'true'
env:
BASELINE_PATH: ${{ runner.temp }}/cloud-run-deployment-baseline.sha256
run: |
set -euo pipefail
summarize() {
"$@" | sha256sum | cut -d ' ' -f1
}
active_traffic_digest() {
gcloud run services describe "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--format='json(status.traffic)' \
| python3 -c 'import json, sys; traffic = (json.load(sys.stdin).get("status", {}).get("traffic") or []); active = [{"revisionName": row.get("revisionName"), "percent": int(row.get("percent", 0))} for row in traffic if isinstance(row, dict) and int(row.get("percent", 0)) > 0]; print(json.dumps(sorted(active, key=lambda row: (str(row["revisionName"]), row["percent"])), separators=(",", ":")))' \
| sha256sum | cut -d ' ' -f1
}
{
echo "traffic=$(active_traffic_digest)"
echo "config=$(summarize gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(spec.template.spec.serviceAccountName,spec.template.spec.containerConcurrency,spec.template.spec.timeoutSeconds,spec.template.spec.containers[].resources,spec.template.spec.containers[].env[].name,spec.template.spec.containers[].env[].valueFrom.secretKeyRef)')"
echo "scheduler=$(summarize gcloud scheduler jobs list --project="${GCP_PROJECT_ID}" --location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" --sort-by=name --format='json(name,state,schedule,timeZone,httpTarget.uri,httpTarget.httpMethod,httpTarget.oidcToken.serviceAccountEmail,httpTarget.oidcToken.audience)')"
echo "iam=$(summarize gcloud run services get-iam-policy "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(bindings[].role,bindings[].members,bindings[].condition)')"
} > "${BASELINE_PATH}"
echo "path=${BASELINE_PATH}" >> "${GITHUB_OUTPUT}"
- name: Set up Python for strategy requirement resolution
if: steps.deploy_config.outputs.enabled == 'true'
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install strategy status dependencies
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
python -m pip install --upgrade pip uv
uv sync --frozen --no-dev
- name: Verify deployed runtime target admission before traffic shift
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
uv run --no-sync python scripts/verify_deployed_runtime_target_admission.py \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--service="${CLOUD_RUN_SERVICE}"
- name: Verify production strategy dependency continuity before build
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
python scripts/verify_production_ues_continuity.py \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--service="${CLOUD_RUN_SERVICE}"
- name: Build, push, and deploy Cloud Run image
id: deploy_image
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
image_repo="${GCP_ARTIFACT_REGISTRY_HOSTNAME}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/firstradeplatform/${CLOUD_RUN_SERVICE}"
image="${image_repo}:${GITHUB_SHA}"
gcloud auth configure-docker "${GCP_ARTIFACT_REGISTRY_HOSTNAME}" --quiet
docker build --pull -t "${image}" .
docker push "${image}"
image_digest="$(gcloud artifacts docker images describe "${image}" \
--project="${GCP_PROJECT_ID}" \
--format='value(image_summary.digest)')"
if [ -z "${image_digest}" ]; then
echo "Unable to resolve the pushed image digest." >&2
exit 1
fi
echo "image_digest=${image_digest}" >> "${GITHUB_OUTPUT}"
gcloud run deploy "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--platform=managed \
--image="${image}" \
--service-account="${GCP_RUNTIME_SERVICE_ACCOUNT}" \
--no-allow-unauthenticated \
--ingress=internal \
--max-instances=1 \
--concurrency=1 \
--memory=512Mi \
--cpu=1 \
--timeout=300s \
--labels="managed-by=github-actions,commit-sha=${GITHUB_SHA},github-run-id=${GITHUB_RUN_ID}" \
--no-traffic \
--quiet
- name: Verify no-traffic deployment readback
if: steps.deploy_config.outputs.enabled == 'true'
env:
BASELINE_PATH: ${{ steps.deployment_baseline.outputs.path }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
EXPECTED_IMAGE_DIGEST: ${{ steps.deploy_image.outputs.image_digest }}
run: |
set -euo pipefail
summarize() { "$@" | sha256sum | cut -d ' ' -f1; }
active_traffic_digest() {
gcloud run services describe "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--format='json(status.traffic)' \
| python3 -c 'import json, sys; traffic = (json.load(sys.stdin).get("status", {}).get("traffic") or []); active = [{"revisionName": row.get("revisionName"), "percent": int(row.get("percent", 0))} for row in traffic if isinstance(row, dict) and int(row.get("percent", 0)) > 0]; print(json.dumps(sorted(active, key=lambda row: (str(row["revisionName"]), row["percent"])), separators=(",", ":")))' \
| sha256sum | cut -d ' ' -f1
}
fail_readback() { echo "No-traffic deployment readback failed: $1" >&2; exit 1; }
traffic="$(awk -F= '$1 == "traffic" { print $2 }' "${BASELINE_PATH}")"
config="$(awk -F= '$1 == "config" { print $2 }' "${BASELINE_PATH}")"
scheduler="$(awk -F= '$1 == "scheduler" { print $2 }' "${BASELINE_PATH}")"
iam="$(awk -F= '$1 == "iam" { print $2 }' "${BASELINE_PATH}")"
revision_name="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='value(status.latestCreatedRevisionName)')"
revision_sha="$(gcloud run revisions describe "${revision_name}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='value(metadata.labels.commit-sha)')"
revision_image="$(gcloud run revisions describe "${revision_name}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='value(spec.containers.image)')"
revision_ready="False"
for _ in $(seq 1 30); do
revision_ready="$(
{ gcloud run revisions describe "${revision_name}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(status.conditions)' 2>/dev/null \
| python3 -c 'import json, sys; conditions = (json.load(sys.stdin).get("status", {}).get("conditions") or []); print("True" if any(isinstance(condition, dict) and condition.get("type") == "Ready" and condition.get("status") == "True" for condition in conditions) else "False")' 2>/dev/null; } \
|| printf 'False'
)"
[ "${revision_ready}" = "True" ] && break
sleep 2
done
revision_ready_category="$(
{ gcloud run revisions describe "${revision_name}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(status.conditions)' 2>/dev/null \
| python3 -c 'import json, sys; conditions = (json.load(sys.stdin).get("status", {}).get("conditions") or []); ready = next((condition for condition in conditions if isinstance(condition, dict) and condition.get("type") == "Ready"), {}); reason = str(ready.get("reason") or "").upper(); print("HEALTHCHECK_FAILURE") if "HEALTH" in reason or "PROBE" in reason else print("CONTAINER_FAILURE") if "CONTAINER" in reason or "IMAGE" in reason else print("RESOURCE_FAILURE") if "RESOURCE" in reason or "QUOTA" in reason else print("PROGRESS_DEADLINE") if "DEADLINE" in reason or "TIMEOUT" in reason else print("PLATFORM_FAILURE") if "INTERNAL" in reason or "PLATFORM" in reason else print("UNKNOWN")' 2>/dev/null; } \
|| printf 'UNKNOWN'
)"
if [ "${revision_ready}" != "True" ]; then
echo "CLOUD_RUN_READY_CONDITION_CATEGORY=${revision_ready_category}"
fi
[ "${revision_ready}" = "True" ] || fail_readback "created revision did not become ready"
[ "${revision_sha}" = "${EXPECTED_SHA}" ] || fail_readback "revision commit SHA mismatch"
[[ "${revision_image}" == *"${EXPECTED_IMAGE_DIGEST}"* ]] || fail_readback "revision image digest mismatch"
[ "${traffic}" = "$(active_traffic_digest)" ] || fail_readback "effective traffic changed"
[ "${config}" = "$(summarize gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(spec.template.spec.serviceAccountName,spec.template.spec.containerConcurrency,spec.template.spec.timeoutSeconds,spec.template.spec.containers[].resources,spec.template.spec.containers[].env[].name,spec.template.spec.containers[].env[].valueFrom.secretKeyRef)')" ] || fail_readback "configuration changed"
[ "${scheduler}" = "$(summarize gcloud scheduler jobs list --project="${GCP_PROJECT_ID}" --location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" --sort-by=name --format='json(name,state,schedule,timeZone,httpTarget.uri,httpTarget.httpMethod,httpTarget.oidcToken.serviceAccountEmail,httpTarget.oidcToken.audience)')" ] || fail_readback "Scheduler changed"
[ "${iam}" = "$(summarize gcloud run services get-iam-policy "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(bindings[].role,bindings[].members,bindings[].condition)')" ] || fail_readback "IAM changed"
{
echo "## No-traffic deployment readback"
echo "- Revision commit SHA and image digest verified."
echo "- Traffic, configuration, Scheduler, and IAM summaries unchanged."
} >> "${GITHUB_STEP_SUMMARY}"
- name: Check whether env sync is enabled
id: env_sync_config
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
if [ "${{ inputs.allow_configuration_sync }}" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run configuration sync because allow_configuration_sync is not true." >&2
exit 0
fi
if [ "${ENABLE_GITHUB_ENV_SYNC:-}" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run env sync because ENABLE_GITHUB_ENV_SYNC is not true." >&2
exit 0
fi
echo "enabled=true" >> "$GITHUB_OUTPUT"
- name: Resolve Cloud Run sync targets
id: strategy_requirements
if: steps.env_sync_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)"
{
echo "sync_plan_json<<__SYNC_PLAN_JSON__"
printf '%s\n' "${sync_plan_json}"
echo "__SYNC_PLAN_JSON__"
} >> "$GITHUB_OUTPUT"
- name: Validate env sync inputs
if: steps.env_sync_config.outputs.enabled == 'true'
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
set -euo pipefail
required_vars=(
CLOUD_RUN_REGION
CLOUD_RUN_SERVICE
)
if [ -z "${NOTIFY_LANG:-}" ]; then
required_vars+=(NOTIFY_LANG)
fi
if [ "${FIRSTRADE_RUN_STRATEGY_ON_HTTP:-}" = "true" ] || [ "${FIRSTRADE_RUN_SMOKE_ON_HTTP:-}" = "true" ]; then
if [ -z "${FIRSTRADE_USERNAME_SECRET_NAME:-}" ] && [ -z "${FIRSTRADE_USERNAME:-}" ]; then
required_vars+=("FIRSTRADE_USERNAME_SECRET_NAME or FIRSTRADE_USERNAME")
fi
if [ -z "${FIRSTRADE_PASSWORD_SECRET_NAME:-}" ] && [ -z "${FIRSTRADE_PASSWORD:-}" ]; then
required_vars+=("FIRSTRADE_PASSWORD_SECRET_NAME or FIRSTRADE_PASSWORD")
fi
fi
if [ -n "${GLOBAL_TELEGRAM_CHAT_ID:-}" ] \
&& [ -z "${TELEGRAM_TOKEN_SECRET_NAME:-}" ] \
&& [ -z "${TELEGRAM_TOKEN:-}" ]; then
required_vars+=("TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN")
fi
if [ -n "${TELEGRAM_TOKEN_SECRET_NAME:-}${TELEGRAM_TOKEN:-}" ] \
&& [ -z "${GLOBAL_TELEGRAM_CHAT_ID:-}" ]; then
required_vars+=(GLOBAL_TELEGRAM_CHAT_ID)
fi
missing_vars=()
for var_name in "${required_vars[@]}"; do
if [[ "${var_name}" == *" or "* ]]; then
missing_vars+=("${var_name}")
elif [ -z "${!var_name:-}" ]; then
missing_vars+=("${var_name}")
fi
done
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")
matching_targets = [
candidate
for candidate in targets
if str(candidate.get("service_name") or "").strip()
== os.environ["CLOUD_RUN_SERVICE"]
]
if len(matching_targets) != 1:
raise SystemExit(
f"Expected exactly one Cloud Run sync target for {os.environ['CLOUD_RUN_SERVICE']!r}"
)
target = matching_targets[0]
service_name = str(target.get("service_name") or "").strip()
if not service_name:
raise SystemExit("Cloud Run sync target is missing service_name")
if service_name != os.environ["CLOUD_RUN_SERVICE"]:
raise SystemExit(
f"Cloud Run sync target {service_name!r} does not match CLOUD_RUN_SERVICE"
)
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: Wait for Cloud Run deployment of current commit
if: steps.deploy_config.outputs.enabled == 'true' && steps.env_sync_config.outputs.enabled == 'true'
run: |
set -euo pipefail
target_sha="${GITHUB_SHA}"
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:-<none>}" >&2
exit 1
fi
echo "Waiting for Cloud Run service ${CLOUD_RUN_SERVICE} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-<none>}" >&2
sleep 10
done
- name: Sync Cloud Run environment
if: steps.env_sync_config.outputs.enabled == 'true'
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
set -euo pipefail
mapfile -t target_env_pairs < <(python - <<'PY'
import json
import os
plan = json.loads(os.environ["SYNC_PLAN_JSON"])
matching_targets = [
candidate
for candidate in plan.get("targets") or []
if str(candidate.get("service_name") or "").strip()
== os.environ["CLOUD_RUN_SERVICE"]
]
if len(matching_targets) != 1:
raise SystemExit(
f"Expected exactly one Cloud Run sync target for {os.environ['CLOUD_RUN_SERVICE']!r}"
)
target = matching_targets[0]
for key, value in sorted(target["env"].items()):
print(f"{key}={value}")
PY
)
mapfile -t target_remove_env_vars < <(python - <<'PY'
import json
import os
plan = json.loads(os.environ["SYNC_PLAN_JSON"])
matching_targets = [
candidate
for candidate in plan.get("targets") or []
if str(candidate.get("service_name") or "").strip()
== os.environ["CLOUD_RUN_SERVICE"]
]
if len(matching_targets) != 1:
raise SystemExit(
f"Expected exactly one Cloud Run sync target for {os.environ['CLOUD_RUN_SERVICE']!r}"
)
target = matching_targets[0]
for key in sorted(target.get("remove_env_vars") or []):
print(key)
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}"
}
env_pairs=("${target_env_pairs[@]}")
env_pairs+=("GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID}")
secret_pairs=()
remove_env_vars=("${target_remove_env_vars[@]}")
remove_env_vars+=(
"TELEGRAM_CHAT_ID"
"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"
)
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"
)
add_optional_secret() {
local env_name="$1"
local secret_var_name="$2"
local secret_value_var_name="$3"
local secret_name="${!secret_var_name:-}"
local plain_value="${!secret_value_var_name:-}"
if [ -n "${secret_name}" ]; then
secret_pairs+=("${env_name}=${secret_name}:latest")
remove_env_vars+=("${env_name}")
elif [ -n "${plain_value}" ]; then
env_pairs+=("${env_name}=${plain_value}")
remove_secret_vars+=("${env_name}")
else
remove_env_vars+=("${env_name}")
remove_secret_vars+=("${env_name}")
fi
}
add_optional_secret TELEGRAM_TOKEN TELEGRAM_TOKEN_SECRET_NAME TELEGRAM_TOKEN
add_optional_secret STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD
add_optional_secret STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN
add_optional_secret STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN
add_optional_secret STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN
add_optional_secret STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN
add_optional_secret FIRSTRADE_USERNAME FIRSTRADE_USERNAME_SECRET_NAME FIRSTRADE_USERNAME
add_optional_secret FIRSTRADE_PASSWORD FIRSTRADE_PASSWORD_SECRET_NAME FIRSTRADE_PASSWORD
add_optional_secret FIRSTRADE_MFA_SECRET FIRSTRADE_MFA_SECRET_SECRET_NAME FIRSTRADE_MFA_SECRET
add_optional_secret FIRSTRADE_PIN FIRSTRADE_PIN_SECRET_NAME FIRSTRADE_PIN
add_optional_secret FIRSTRADE_MFA_EMAIL FIRSTRADE_MFA_EMAIL_SECRET_NAME FIRSTRADE_MFA_EMAIL
add_optional_secret FIRSTRADE_MFA_PHONE FIRSTRADE_MFA_PHONE_SECRET_NAME FIRSTRADE_MFA_PHONE
add_optional_secret FIRSTRADE_MFA_CODE FIRSTRADE_MFA_CODE_SECRET_NAME FIRSTRADE_MFA_CODE
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 monitor dispatcher targets." >&2
exit 1
fi
monitor_targets_json="$(
SERVICE_URL="${service_url}" python - <<'PY'
import json
import os
plan = json.loads(os.environ["SYNC_PLAN_JSON"])
matching_targets = [
candidate
for candidate in plan.get("targets") or []
if str(candidate.get("service_name") or "").strip()
== os.environ["CLOUD_RUN_SERVICE"]
]
if len(matching_targets) != 1:
raise SystemExit(
f"Expected exactly one Cloud Run sync target for {os.environ['CLOUD_RUN_SERVICE']!r}"
)
target = matching_targets[0]
env = target.get("env") or {}
runtime_target = json.loads(env.get("RUNTIME_TARGET_JSON") or "{}")
payload = {
"service_name": target.get("service_name") or os.environ.get("CLOUD_RUN_SERVICE"),
"service_url": os.environ.get("SERVICE_URL"),
"strategy_profile": target.get("strategy_profile") or env.get("STRATEGY_PROFILE"),
"account_scope": runtime_target.get("account_scope"),
"runtime_target_enabled": env.get("RUNTIME_TARGET_ENABLED", "true"),
"scheduler": target.get("scheduler") or {},
}
print(json.dumps({"targets": [payload]}, separators=(",", ":")))
PY
)"
env_pairs+=("MONITOR_DISPATCH_TARGETS_JSON=${monitor_targets_json}")
gcloud_args=(
run services update "${CLOUD_RUN_SERVICE}"
--region "${CLOUD_RUN_REGION}"
--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
gcloud "${gcloud_args[@]}"
- name: Reconcile Cloud Run traffic
if: steps.env_sync_config.outputs.enabled == 'true' && inputs.allow_traffic_promotion == true
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
set -euo pipefail
python scripts/reconcile_cloud_runtime.py traffic
- name: Sync Cloud Scheduler schedule
id: scheduler_sync
if: steps.env_sync_config.outputs.enabled == 'true'
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
DIRECT_MONITOR_MIGRATION_COMPLETE: ${{ vars.DIRECT_MONITOR_MIGRATION_COMPLETE }}
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
mapfile -t scheduler_config < <(python - <<'PY'
import json
import os
plan = json.loads(os.environ["SYNC_PLAN_JSON"])
targets = plan.get("targets") or []
matching_targets = [
candidate
for candidate in targets
if str(candidate.get("service_name") or "").strip() == os.environ["CLOUD_RUN_SERVICE"]
]
if len(matching_targets) != 1:
raise SystemExit(
f"Expected exactly one scheduler target for {os.environ['CLOUD_RUN_SERVICE']!r}"
)
target = matching_targets[0]
scheduler = target.get("scheduler") or {}
target_env = target.get("env") or {}
print(str(scheduler.get("timezone") or "America/New_York").strip())
print(str(scheduler.get("main_time") or os.environ.get("CLOUD_SCHEDULER_MAIN_TIME", "").strip() or "45 15"))
print(str(scheduler.get("probe_time") or os.environ.get("CLOUD_SCHEDULER_PROBE_TIME", "").strip() or "35 9,15"))
print(str(scheduler.get("precheck_time") or os.environ.get("CLOUD_SCHEDULER_PRECHECK_TIME", "").strip() or "45 9"))
print(str(target_env.get("RUNTIME_TARGET_ENABLED") or "true").strip().lower())
PY
)
market_timezone="${scheduler_config[0]}"
main_time="${scheduler_config[1]}"
probe_time="${scheduler_config[2]}"
precheck_time="${scheduler_config[3]}"
runtime_target_enabled="${scheduler_config[4]}"
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=("${CLOUD_RUN_SERVICE}-scheduler")
if [[ "${CLOUD_RUN_SERVICE}" == *-service ]]; then
scheduler_job_candidates+=("${CLOUD_RUN_SERVICE%-service}-scheduler")
fi
job_name=""
current_schedule=""
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}"
break
fi
done
if [ -z "${job_name}" ]; then
job_name="${scheduler_job_candidates[0]}"
fi
if [ -n "${current_schedule}" ]; then
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
)"
else
desired_schedule="$(SCHEDULE_TIME="${main_time}" python - <<'PY'
import os
fields = os.environ["SCHEDULE_TIME"].split()
if len(fields) == 5:
print(" ".join(fields))
elif len(fields) == 2:
print(" ".join([*fields, "*", "*", "*"]))
else:
raise SystemExit(
f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}"
)
PY
)"
fi
scheduler_uri="${service_url}/run"
if [ -n "${current_schedule}" ]; then
echo "Updating Cloud Scheduler job ${job_name} schedule to ${desired_schedule}, timezone to ${market_timezone}, and URI to ${scheduler_uri}."
gcloud scheduler jobs update http "${job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${scheduler_uri}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--schedule="${desired_schedule}" \
--time-zone="${market_timezone}" \
--max-retry-attempts=3 \
--min-backoff=300s \
--max-backoff=1800s \
--max-doublings=2 \
--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}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--schedule="${desired_schedule}" \
--time-zone="${market_timezone}" \
--attempt-deadline=600s \
--max-retry-attempts=3 \
--min-backoff=300s \
--max-backoff=1800s \
--max-doublings=2 \
--quiet
fi
managed_scheduler_jobs=("${job_name}")
probe_job_name="${CLOUD_RUN_SERVICE}-probe-scheduler"
probe_uri="${service_url}/probe"
precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler"
precheck_uri="${service_url}/dry-run"
if [ "${DIRECT_MONITOR_MIGRATION_COMPLETE:-}" = "true" ]; then
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
)"
desired_probe_schedule="$(CURRENT_SCHEDULE="${desired_schedule}" SCHEDULE_TIME="${probe_time}" python - <<'PY'
import os
current_fields = os.environ["CURRENT_SCHEDULE"].split()
time_fields = os.environ["SCHEDULE_TIME"].split()
if len(current_fields) != 5:
raise SystemExit(f"Cloud Scheduler schedule must have 5 fields: {os.environ['CURRENT_SCHEDULE']!r}")
if len(time_fields) == 5:
print(" ".join(time_fields))
elif len(time_fields) == 2:
print(" ".join([*time_fields, *current_fields[2:]]))
else:
raise SystemExit(
f"Cloud Scheduler override must have 2 time fields or 5 cron fields: {os.environ['SCHEDULE_TIME']!r}"
)
PY
)"
if gcloud scheduler jobs describe "${probe_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" >/dev/null 2>&1; then
echo "Updating Cloud Scheduler probe ${probe_job_name} to ${desired_probe_schedule}."
gcloud scheduler jobs update http "${probe_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${probe_uri}" \
--schedule="${desired_probe_schedule}" \
--time-zone="${market_timezone}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--attempt-deadline=180s \
--max-retry-attempts=0 \
--max-retry-duration=0s \
--quiet
else
echo "Creating Cloud Scheduler probe ${probe_job_name} at ${desired_probe_schedule}."
gcloud scheduler jobs create http "${probe_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${probe_uri}" \
--schedule="${desired_probe_schedule}" \
--time-zone="${market_timezone}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--attempt-deadline=180s \
--max-retry-attempts=0 \
--max-retry-duration=0s \
--quiet
fi
if gcloud scheduler jobs describe "${precheck_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" >/dev/null 2>&1; then
echo "Updating Cloud Scheduler precheck ${precheck_job_name} to ${desired_precheck_schedule}."
gcloud scheduler jobs update http "${precheck_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${precheck_uri}" \
--schedule="${desired_precheck_schedule}" \
--time-zone="${market_timezone}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--attempt-deadline=180s \
--max-retry-attempts=0 \
--max-retry-duration=0s \
--quiet
else
echo "Creating Cloud Scheduler precheck ${precheck_job_name} at ${desired_precheck_schedule}."
gcloud scheduler jobs create http "${precheck_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${precheck_uri}" \
--schedule="${desired_precheck_schedule}" \
--time-zone="${market_timezone}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--attempt-deadline=180s \
--max-retry-attempts=0 \
--max-retry-duration=0s \
--quiet
fi
managed_scheduler_jobs+=("${probe_job_name}" "${precheck_job_name}")
else
monitor_job_name="firstrade-monitor-dispatcher-scheduler"
monitor_uri="${service_url}/monitor-dispatch"
if gcloud scheduler jobs describe "${monitor_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" >/dev/null 2>&1; then
echo "Updating Cloud Scheduler job ${monitor_job_name} to ${monitor_uri}."
gcloud scheduler jobs update http "${monitor_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${monitor_uri}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--schedule="*/5 * * * *" \
--time-zone="UTC" \
--attempt-deadline=180s \
--max-retry-attempts=0 \
--max-retry-duration=0s \
--quiet
else
echo "Creating Cloud Scheduler job ${monitor_job_name} at ${monitor_uri}."
gcloud scheduler jobs create http "${monitor_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${monitor_uri}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--schedule="*/5 * * * *" \
--time-zone="UTC" \
--attempt-deadline=180s \
--max-retry-attempts=0 \
--max-retry-duration=0s \
--quiet
fi
managed_scheduler_jobs+=("${monitor_job_name}")
invoke_bridge_jobs=(
"${probe_job_name}|${probe_uri}"
"${precheck_job_name}|${precheck_uri}"
)
for bridge_entry in "${invoke_bridge_jobs[@]}"; do
bridge_job="${bridge_entry%%|*}"
bridge_uri="${bridge_entry#*|}"
if gcloud scheduler jobs describe "${bridge_job}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" >/dev/null 2>&1; then
echo "Updating dormant invoke-bridge Cloud Scheduler job ${bridge_job}."
gcloud scheduler jobs update http "${bridge_job}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${bridge_uri}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--schedule="0 0 1 1 *" \
--time-zone="UTC" \
--attempt-deadline=600s \
--quiet
else
echo "Creating dormant invoke-bridge Cloud Scheduler job ${bridge_job}."
gcloud scheduler jobs create http "${bridge_job}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--uri="${bridge_uri}" \
--http-method=POST \
--oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \
--oidc-token-audience="${service_url}" \
--schedule="0 0 1 1 *" \
--time-zone="UTC" \
--attempt-deadline=600s \
--quiet
fi
managed_scheduler_jobs+=("${bridge_job}")
done
fi
for managed_job_name in "${managed_scheduler_jobs[@]}"; do
managed_job_state="$(gcloud scheduler jobs describe "${managed_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--format='value(state)')"
case "${runtime_target_enabled}" in
1|true|yes|on)
if [ "${managed_job_state}" = "PAUSED" ]; then
echo "Resuming Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is enabled."
gcloud scheduler jobs resume "${managed_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--quiet
fi
;;
*)
if [ "${managed_job_state}" != "PAUSED" ]; then
echo "Pausing Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is disabled."
gcloud scheduler jobs pause "${managed_job_name}" \
--project="${GCP_PROJECT_ID}" \
--location="${scheduler_location}" \
--quiet
fi
;;
esac
done
if [ "${DIRECT_MONITOR_MIGRATION_COMPLETE:-}" = "true" ]; then
echo "direct_monitors_reconciled=true" >> "${GITHUB_OUTPUT}"
fi
- name: Reconcile legacy Cloud Scheduler jobs
if: steps.env_sync_config.outputs.enabled == 'true'
env:
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
DIRECT_MONITOR_MIGRATION_COMPLETE: ${{ vars.DIRECT_MONITOR_MIGRATION_COMPLETE }}
DIRECT_MONITOR_SCHEDULERS_RECONCILED: ${{ steps.scheduler_sync.outputs.direct_monitors_reconciled }}
run: |
set -euo pipefail
python scripts/reconcile_cloud_runtime.py scheduler-cleanup
- name: Clean up old Cloud Run revisions and images
if: steps.deploy_config.outputs.enabled == 'true' && inputs.allow_cleanup == true && env.CLOUD_RUN_CLEANUP_ENABLED == 'true'
run: |
set -euo pipefail
service_json="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--format=json)"
latest_revision="$(python -c 'import json,sys; print(json.load(sys.stdin)["status"]["latestReadyRevisionName"])' <<< "${service_json}")"
traffic_revisions="$(python -c 'import json,sys; svc=json.load(sys.stdin); print("\n".join(t.get("revisionName","") for t in svc.get("status",{}).get("traffic",[]) if t.get("revisionName") and int(t.get("percent",0)) > 0))' <<< "${service_json}")"
keep_revision() {
local revision="$1"
if [ "${revision}" = "${latest_revision}" ]; then
return 0
fi
if printf '%s\n' "${traffic_revisions}" | grep -Fxq "${revision}"; then
return 0
fi
return 1
}
while IFS= read -r revision; do
if [ -z "${revision}" ] || keep_revision "${revision}"; then
continue
fi
gcloud run revisions delete "${revision}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--quiet 2>&1 || true
done < <(gcloud run revisions list \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--service="${CLOUD_RUN_SERVICE}" \
--format='value(metadata.name)')
image_repo="${GCP_ARTIFACT_REGISTRY_HOSTNAME}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/firstradeplatform/${CLOUD_RUN_SERVICE}"
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["GITHUB_SHA"]; rows=json.load(sys.stdin); print("\n".join(row["version"] for row in rows if keep not in set(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}"