Deploy Cloud Run #390
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Deploy Cloud Run | |
| on: | |
| workflow_run: | |
| workflows: [CI] | |
| types: [completed] | |
| branches: [main] | |
| workflow_dispatch: | |
| env: | |
| GCP_PROJECT_ID: charlesschwabquant | |
| GCP_WORKLOAD_IDENTITY_PROVIDER: projects/401309731911/locations/global/workloadIdentityPools/github-actions/providers/github-main | |
| GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: schwab-platform-deploy@charlesschwabquant.iam.gserviceaccount.com | |
| GCP_RUNTIME_SERVICE_ACCOUNT: schwab-platform-runtime@charlesschwabquant.iam.gserviceaccount.com | |
| GCP_SCHEDULER_SERVICE_ACCOUNT: schwab-platform-scheduler@charlesschwabquant.iam.gserviceaccount.com | |
| GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| sync-cloud-run-env: | |
| name: Deploy / Sync Cloud Run | |
| if: > | |
| github.event_name == 'workflow_dispatch' || | |
| ( | |
| github.event_name == 'workflow_run' && | |
| github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.event == 'push' | |
| ) | |
| 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 }} | |
| GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} | |
| CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} | |
| CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} | |
| 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 }} | |
| GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_CLOUD_PROJECT }} | |
| TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} | |
| SCHWAB_API_KEY_SECRET_NAME: ${{ vars.SCHWAB_API_KEY_SECRET_NAME }} | |
| SCHWAB_APP_SECRET_SECRET_NAME: ${{ vars.SCHWAB_APP_SECRET_SECRET_NAME }} | |
| RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} | |
| SCHWAB_DRY_RUN_ONLY: ${{ vars.SCHWAB_DRY_RUN_ONLY }} | |
| SCHWAB_FEATURE_SNAPSHOT_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_PATH }} | |
| SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH }} | |
| SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE }} | |
| SCHWAB_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }} | |
| SCHWAB_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MAX_STALE_DAYS }} | |
| SCHWAB_STRATEGY_CONFIG_PATH: ${{ vars.SCHWAB_STRATEGY_CONFIG_PATH }} | |
| SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON }} | |
| SCHWAB_MIN_RESERVED_CASH_USD: ${{ vars.SCHWAB_MIN_RESERVED_CASH_USD }} | |
| SCHWAB_RESERVED_CASH_RATIO: ${{ vars.SCHWAB_RESERVED_CASH_RATIO }} | |
| SCHWAB_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.SCHWAB_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} | |
| SCHWAB_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.SCHWAB_MARKET_SIGNAL_HANDOFF_INDEX_URI }} | |
| SCHWAB_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.SCHWAB_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} | |
| SCHWAB_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.SCHWAB_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} | |
| SCHWAB_MARKET_SIGNAL_CACHE_DIR: ${{ vars.SCHWAB_MARKET_SIGNAL_CACHE_DIR }} | |
| SCHWAB_MARKET_SIGNAL_REQUIRED: ${{ vars.SCHWAB_MARKET_SIGNAL_REQUIRED }} | |
| SCHWAB_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.SCHWAB_MARKET_SIGNAL_FALLBACK_MODE }} | |
| SCHWAB_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.SCHWAB_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 }} | |
| # Optional strategy overrides; leave unset to inherit the UsEquityStrategies profile defaults. | |
| 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 }} | |
| SCHWAB_CASH_ONLY_EXECUTION: ${{ vars.SCHWAB_CASH_ONLY_EXECUTION }} | |
| CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }} | |
| RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} | |
| DUAL_DRIVE_UNLEVERED_SYMBOL: ${{ vars.DUAL_DRIVE_UNLEVERED_SYMBOL }} | |
| EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} | |
| GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} | |
| NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} | |
| TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} | |
| SCHWAB_API_KEY: ${{ secrets.SCHWAB_API_KEY }} | |
| SCHWAB_APP_SECRET: ${{ secrets.SCHWAB_APP_SECRET }} | |
| 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.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_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 | |
| # 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" \ | |
| "enabled=false" | |
| echo "Skipping Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2 | |
| exit 0 | |
| fi | |
| if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ]; then | |
| deploy_enabled=true | |
| fi | |
| if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ]; then | |
| env_sync_enabled=true | |
| fi | |
| write_github_output \ | |
| "deploy_enabled=${deploy_enabled}" \ | |
| "env_sync_enabled=${env_sync_enabled}" | |
| if [ "${deploy_enabled}" != "true" ] && [ "${env_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.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} | |
| - name: Set up Python for strategy requirement resolution | |
| if: steps.config.outputs.env_sync_enabled == 'true' | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Install strategy status dependencies | |
| if: steps.config.outputs.env_sync_enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| python -m pip install --upgrade pip uv | |
| uv sync --frozen --no-dev | |
| - name: Resolve Cloud Run sync targets | |
| id: strategy_requirements | |
| if: steps.config.outputs.env_sync_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.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 | |
| CLOUD_RUN_SERVICE | |
| ) | |
| 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 | |
| if [ -z "${SCHWAB_API_KEY_SECRET_NAME:-}" ] && [ -z "${SCHWAB_API_KEY:-}" ]; then | |
| missing_vars+=("SCHWAB_API_KEY_SECRET_NAME or SCHWAB_API_KEY") | |
| fi | |
| if [ -z "${SCHWAB_APP_SECRET_SECRET_NAME:-}" ] && [ -z "${SCHWAB_APP_SECRET:-}" ]; then | |
| missing_vars+=("SCHWAB_APP_SECRET_SECRET_NAME or SCHWAB_APP_SECRET") | |
| 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 | |
| run: | | |
| set -euo pipefail | |
| missing_vars=() | |
| for var_name in CLOUD_RUN_REGION CLOUD_RUN_SERVICE; 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 these values are missing:" >&2 | |
| printf ' - %s\n' "${missing_vars[@]}" >&2 | |
| exit 1 | |
| fi | |
| - 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: Build, push, and deploy Cloud Run image | |
| 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}/charlesschwabplatform/${CLOUD_RUN_SERVICE}" | |
| image="${image_repo}:${GITHUB_SHA}" | |
| gcloud auth configure-docker "${artifact_registry_hostname}" --quiet | |
| docker build --pull -t "${image}" . | |
| docker push "${image}" | |
| gcloud run deploy "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --platform=managed \ | |
| --image="${image}" \ | |
| --service-account="${GCP_RUNTIME_SERVICE_ACCOUNT}" \ | |
| --ingress=internal \ | |
| --max-instances=1 \ | |
| --memory=512Mi \ | |
| --cpu=1 \ | |
| --timeout=300s \ | |
| --labels="managed-by=github-actions,commit-sha=${GITHUB_SHA},github-run-id=${GITHUB_RUN_ID}" \ | |
| --quiet | |
| - name: Wait for Cloud Run deployment of current commit | |
| if: steps.config.outputs.env_sync_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.config.outputs.env_sync_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"]) | |
| target = plan["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"]) | |
| target = plan["targets"][0] | |
| for key in sorted(target.get("remove_env_vars") or []): | |
| print(key) | |
| PY | |
| ) | |
| env_pairs=("${target_env_pairs[@]}") | |
| 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" | |
| ) | |
| if [ -n "${TELEGRAM_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("TELEGRAM_TOKEN") | |
| else | |
| env_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN}") | |
| remove_secret_vars+=("TELEGRAM_TOKEN") | |
| fi | |
| if [ -n "${SCHWAB_API_KEY_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("SCHWAB_API_KEY=${SCHWAB_API_KEY_SECRET_NAME}:latest") | |
| remove_env_vars+=("SCHWAB_API_KEY") | |
| else | |
| env_pairs+=("SCHWAB_API_KEY=${SCHWAB_API_KEY}") | |
| remove_secret_vars+=("SCHWAB_API_KEY") | |
| fi | |
| if [ -n "${SCHWAB_APP_SECRET_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("SCHWAB_APP_SECRET=${SCHWAB_APP_SECRET_SECRET_NAME}:latest") | |
| remove_env_vars+=("SCHWAB_APP_SECRET") | |
| else | |
| env_pairs+=("SCHWAB_APP_SECRET=${SCHWAB_APP_SECRET}") | |
| remove_secret_vars+=("SCHWAB_APP_SECRET") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN=${STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN=${STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME:-}" ]; then | |
| secret_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME}:latest") | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| elif [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN}") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| remove_secret_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_CHANNELS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_CHANNELS=${STRATEGY_PLUGIN_ALERT_CHANNELS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_CHANNELS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL=${STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY=${STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER=${STRATEGY_PLUGIN_ALERT_SMS_PROVIDER}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_PROVIDER") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID=${STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_SENDER:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER=${STRATEGY_PLUGIN_ALERT_SMS_SENDER}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_SENDER") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID=${STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS=${STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER=${STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE=${STRATEGY_PLUGIN_ALERT_PUSH_DEVICE}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_DEVICE") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY=${STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_TAGS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS=${STRATEGY_PLUGIN_ALERT_PUSH_TAGS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_TAGS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL=${STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE=${STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW=${STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW") | |
| fi | |
| if [ -n "${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS:-}" ]; then | |
| env_pairs+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS=${STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS}") | |
| else | |
| remove_env_vars+=("STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS") | |
| fi | |
| 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}" | |
| } | |
| 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"]) | |
| target = plan["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: Verify strategy plugin mounts | |
| if: steps.config.outputs.env_sync_enabled == 'true' | |
| env: | |
| STRATEGY_PLUGIN_MOUNT_ENV_NAMES: SCHWAB_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.env_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 | |
| mapfile -t scheduler_config < <(python - <<'PY' | |
| import json | |
| import os | |
| plan = json.loads(os.environ["SYNC_PLAN_JSON"]) | |
| target = plan["targets"][0] | |
| scheduler = target.get("scheduler") 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")) | |
| PY | |
| ) | |
| market_timezone="${scheduler_config[0]}" | |
| main_time="${scheduler_config[1]}" | |
| probe_time="${scheduler_config[2]}" | |
| precheck_time="${scheduler_config[3]}" | |
| # Keep probe/precheck parsed for runtime-target schema parity; this step only syncs the main scheduler. | |
| : "${probe_time}" "${precheck_time}" | |
| 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}" \ | |
| --schedule="${desired_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --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 \ | |
| --quiet | |
| fi | |
| monitor_job_name="schwab-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 \ | |
| --quiet | |
| else | |
| echo "Creating Cloud Scheduler job ${monitor_job_name} at ${monitor_uri}." | |
| gcloud scheduler jobs create http "${monitor_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${monitor_uri}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --schedule="*/5 * * * *" \ | |
| --time-zone="UTC" \ | |
| --attempt-deadline=180s \ | |
| --quiet | |
| fi | |
| legacy_jobs=( | |
| "${CLOUD_RUN_SERVICE}-probe-scheduler" | |
| "${CLOUD_RUN_SERVICE}-precheck-scheduler" | |
| ) | |
| if [[ "${CLOUD_RUN_SERVICE}" == *-service ]]; then | |
| legacy_jobs+=( | |
| "${CLOUD_RUN_SERVICE%-service}-probe-scheduler" | |
| "${CLOUD_RUN_SERVICE%-service}-precheck-scheduler" | |
| ) | |
| fi | |
| for legacy_job in "${legacy_jobs[@]}"; do | |
| if gcloud scheduler jobs describe "${legacy_job}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" >/dev/null 2>&1; then | |
| echo "Deleting legacy Cloud Scheduler job ${legacy_job}; monitor dispatcher now owns probe/precheck." | |
| gcloud scheduler jobs delete "${legacy_job}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --quiet | |
| fi | |
| done | |
| - name: Prune old Cloud Run revisions | |
| if: steps.config.outputs.enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| keep_revisions="$( | |
| gcloud run services describe "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --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 | |
| exit 0 | |
| fi | |
| mapfile -t revisions_to_delete < <( | |
| gcloud run revisions list \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --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}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --region="${CLOUD_RUN_REGION}" \ | |
| --quiet 2>&1 || true | |
| done | |
| - name: Clean up old Cloud Run images | |
| 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}/charlesschwabplatform/${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}" |