Image-only no-traffic staging #601
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 | |
| run-name: ${{ inputs.deployment_mode == 'image-only-no-traffic' && 'Image-only no-traffic staging' || 'Deploy Cloud Run' }} | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| deployment_mode: | |
| description: "Legacy deployment or explicit single-target image-only staging without traffic." | |
| required: true | |
| type: choice | |
| default: legacy | |
| options: | |
| - legacy | |
| - image-only-no-traffic | |
| approved_ref: | |
| description: "Exact workflow branch/tag name approved for image-only staging." | |
| required: false | |
| type: string | |
| source_commit: | |
| description: "Full approved source SHA; must equal the dispatched workflow SHA." | |
| required: false | |
| type: string | |
| target: | |
| description: "Legacy: configured or isolated verify targets. Image-only: exactly PAPER, HK, or SG." | |
| required: true | |
| type: choice | |
| default: configured | |
| options: | |
| - configured | |
| - hk-verify | |
| - paper-command-verify | |
| - PAPER | |
| - HK | |
| - SG | |
| cloud_run_region: | |
| description: "Cloud Run region for hk-verify. Leave blank to use the longbridge-hk Environment value." | |
| required: false | |
| type: string | |
| cloud_run_service: | |
| description: "Cloud Run service for hk-verify." | |
| required: false | |
| type: string | |
| default: longbridge-quant-hk-verify-service | |
| longport_secret_name: | |
| description: "LongPort token Secret Manager secret name for hk-verify." | |
| required: false | |
| type: string | |
| default: longport_token_hk | |
| longport_app_key_secret_name: | |
| description: "LongPort app-key Secret Manager secret name for hk-verify." | |
| required: false | |
| type: string | |
| default: longport-app-key-hk | |
| longport_app_secret_secret_name: | |
| description: "LongPort app-secret Secret Manager secret name for hk-verify." | |
| required: false | |
| type: string | |
| default: longport-app-secret-hk | |
| deploy_image: | |
| description: "Build and deploy the container image for hk-verify." | |
| required: true | |
| type: boolean | |
| default: true | |
| sync_env: | |
| description: "Sync hk-verify Cloud Run environment variables." | |
| required: true | |
| type: boolean | |
| default: true | |
| paper_command_verify_service: | |
| description: "Dedicated Cloud Run service for the paper command consumer." | |
| required: false | |
| type: string | |
| default: longbridge-quant-paper-command-verify-service | |
| paper_command_verify_strategy_profile: | |
| description: "Strategy profile to verify with immutable paper commands." | |
| required: false | |
| type: string | |
| default: russell_top50_leader_rotation | |
| paper_command_verify_execution_command_cloud_uri: | |
| description: "Dedicated GCS prefix for immutable paper commands; never an execution-report URI." | |
| required: false | |
| type: string | |
| paper_command_verify_strategy_release_json: | |
| description: "Complete strategy_release identity JSON required for paper command verification." | |
| required: false | |
| type: string | |
| paper_command_verify_longport_secret_name: | |
| description: "LongPort token secret name for the isolated paper command verifier." | |
| required: false | |
| type: string | |
| default: longport_token_paper | |
| paper_command_verify_longport_app_key_secret_name: | |
| description: "LongPort app-key secret name for the isolated paper command verifier." | |
| required: false | |
| type: string | |
| default: longport-app-key-paper | |
| paper_command_verify_longport_app_secret_secret_name: | |
| description: "LongPort app-secret secret name for the isolated paper command verifier." | |
| required: false | |
| type: string | |
| default: longport-app-secret-paper | |
| env: | |
| GCP_PROJECT_ID: longbridgequant | |
| GCP_WORKLOAD_IDENTITY_PROVIDER: projects/252919773759/locations/global/workloadIdentityPools/github-actions/providers/github-main | |
| GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: longbridge-platform-deploy@longbridgequant.iam.gserviceaccount.com | |
| GCP_RUNTIME_SERVICE_ACCOUNT: longbridge-platform-runtime@longbridgequant.iam.gserviceaccount.com | |
| GCP_SCHEDULER_SERVICE_ACCOUNT: longbridge-platform-scheduler@longbridgequant.iam.gserviceaccount.com | |
| GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| image-only-no-traffic: | |
| name: Stage ${{ inputs.target }} image without traffic | |
| if: github.event_name == 'workflow_dispatch' && inputs.deployment_mode == 'image-only-no-traffic' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| id-token: write | |
| environment: ${{ inputs.target == 'PAPER' && 'longbridge-paper' || inputs.target == 'HK' && 'longbridge-hk' || inputs.target == 'SG' && 'longbridge-sg' || '' }} | |
| env: | |
| DEPLOYMENT_MODE: ${{ inputs.deployment_mode }} | |
| WORKFLOW_TARGET: ${{ inputs.target }} | |
| APPROVED_REF: ${{ inputs.approved_ref }} | |
| SOURCE_COMMIT: ${{ inputs.source_commit }} | |
| CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} | |
| CLOUD_RUN_SERVICE: ${{ secrets.CLOUD_RUN_SERVICE }} | |
| GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} | |
| steps: | |
| - name: Validate image-only dispatch | |
| run: | | |
| set -euo pipefail | |
| if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ] || [ "${DEPLOYMENT_MODE:-}" != "image-only-no-traffic" ]; then | |
| echo "Explicit image-only dispatch required." >&2 | |
| exit 1 | |
| fi | |
| case "${WORKFLOW_TARGET:-}" in | |
| PAPER|HK|SG) ;; | |
| *) echo "Image-only staging requires exactly one configured target." >&2; exit 1 ;; | |
| esac | |
| if [ -z "${APPROVED_REF:-}" ] || [ "${APPROVED_REF}" != "${GITHUB_REF_NAME:-}" ] \ | |
| || [[ ! "${SOURCE_COMMIT:-}" =~ ^[0-9a-f]{40}$ ]] || [ "${SOURCE_COMMIT}" != "${GITHUB_SHA:-}" ] \ | |
| || [ "${SOURCE_COMMIT}" != "${GITHUB_WORKFLOW_SHA:-}" ] \ | |
| || [ "${GITHUB_WORKFLOW_REF:-}" != "${GITHUB_REPOSITORY}/.github/workflows/sync-cloud-run-env.yml@${GITHUB_REF}" ]; then | |
| echo "Approved workflow ref and source SHA must match the dispatch." >&2 | |
| exit 1 | |
| fi | |
| if [ -z "${CLOUD_RUN_SERVICE:-}" ] || [ -z "${CLOUD_RUN_REGION:-}" ]; then | |
| echo "Configured service and region are required." >&2 | |
| exit 1 | |
| fi | |
| - name: Checkout exact image-only source | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.sha }} | |
| persist-credentials: false | |
| - name: Verify exact image-only source | |
| run: | | |
| set -euo pipefail | |
| if [ "$(git rev-parse HEAD)" != "${SOURCE_COMMIT}" ]; then | |
| echo "Checked-out source does not match the approved SHA." >&2 | |
| exit 1 | |
| fi | |
| - name: Authenticate selected image-only target | |
| 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 for image-only staging | |
| uses: google-github-actions/setup-gcloud@v3 | |
| with: | |
| project_id: ${{ env.GCP_PROJECT_ID }} | |
| version: ">= 416.0.0" | |
| - name: Build and stage image without traffic | |
| run: | | |
| set -euo pipefail | |
| # Updating, rather than deploying, requires an existing configured service. | |
| existing_service="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='value(metadata.name)')" | |
| if [ "${existing_service}" != "${CLOUD_RUN_SERVICE}" ]; then | |
| echo "Configured service readback did not match." >&2 | |
| exit 1 | |
| fi | |
| artifact_registry_hostname="${GCP_ARTIFACT_REGISTRY_HOSTNAME:-${CLOUD_RUN_REGION}-docker.pkg.dev}" | |
| image_repo="${artifact_registry_hostname}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/longbridgeplatform/${CLOUD_RUN_SERVICE}" | |
| image="${image_repo}:${SOURCE_COMMIT}-${GITHUB_RUN_ID}" | |
| gcloud auth configure-docker "${artifact_registry_hostname}" --quiet | |
| # Auth writes temporary credentials into the workspace; build tracked source only. | |
| git archive HEAD | docker build --pull -t "${image}" - | |
| docker push "${image}" | |
| digest="$(gcloud artifacts docker images describe "${image}" --format='value(image_summary.digest)')" | |
| if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "Published image did not resolve to an immutable digest." >&2 | |
| exit 1 | |
| fi | |
| # Preserve existing configuration and serving revisions. --no-traffic | |
| # pins an existing LATEST selector to its current revision, without moving traffic. | |
| gcloud run services update "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" \ | |
| --image="${image_repo}@${digest}" --no-traffic \ | |
| --update-labels="commit-sha=${SOURCE_COMMIT},github-run-id=${GITHUB_RUN_ID}" --quiet | |
| sync: | |
| name: Deploy / Sync ${{ matrix.target.label }} Cloud Run | |
| # Production deployment and environment sync require an explicit operator dispatch. | |
| if: github.event_name == 'workflow_dispatch' && inputs.deployment_mode != 'image-only-no-traffic' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| target: | |
| - label: PAPER | |
| environment: longbridge-paper | |
| default_account_region: PAPER | |
| - label: HK | |
| environment: longbridge-hk | |
| default_account_region: HK | |
| - label: SG | |
| environment: longbridge-sg | |
| default_account_region: SG | |
| permissions: | |
| actions: read | |
| contents: read | |
| id-token: write | |
| environment: ${{ matrix.target.environment }} | |
| env: | |
| DEPLOYMENT_LABEL: ${{ matrix.target.label }} | |
| GITHUB_ENVIRONMENT_NAME: ${{ matrix.target.environment }} | |
| ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }} | |
| ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} | |
| ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} | |
| QSL_ENABLE_CLOUD_RUN_AUTOMATION: ${{ vars.QSL_ENABLE_CLOUD_RUN_AUTOMATION }} | |
| CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }} | |
| WORKFLOW_TARGET: ${{ inputs.target || 'configured' }} | |
| INPUT_CLOUD_RUN_REGION: ${{ inputs.cloud_run_region }} | |
| INPUT_CLOUD_RUN_SERVICE: ${{ inputs.cloud_run_service }} | |
| INPUT_LONGPORT_SECRET_NAME: ${{ inputs.longport_secret_name }} | |
| INPUT_LONGPORT_APP_KEY_SECRET_NAME: ${{ inputs.longport_app_key_secret_name }} | |
| INPUT_LONGPORT_APP_SECRET_SECRET_NAME: ${{ inputs.longport_app_secret_secret_name }} | |
| INPUT_DEPLOY_IMAGE: ${{ inputs.deploy_image }} | |
| INPUT_SYNC_ENV: ${{ inputs.sync_env }} | |
| INPUT_PAPER_COMMAND_VERIFY_SERVICE: ${{ inputs.paper_command_verify_service }} | |
| INPUT_PAPER_COMMAND_VERIFY_STRATEGY_PROFILE: ${{ inputs.paper_command_verify_strategy_profile }} | |
| INPUT_PAPER_COMMAND_VERIFY_EXECUTION_COMMAND_CLOUD_URI: ${{ inputs.paper_command_verify_execution_command_cloud_uri }} | |
| INPUT_PAPER_COMMAND_VERIFY_STRATEGY_RELEASE_JSON: ${{ inputs.paper_command_verify_strategy_release_json }} | |
| INPUT_PAPER_COMMAND_VERIFY_LONGPORT_SECRET_NAME: ${{ inputs.paper_command_verify_longport_secret_name }} | |
| INPUT_PAPER_COMMAND_VERIFY_LONGPORT_APP_KEY_SECRET_NAME: ${{ inputs.paper_command_verify_longport_app_key_secret_name }} | |
| INPUT_PAPER_COMMAND_VERIFY_LONGPORT_APP_SECRET_SECRET_NAME: ${{ inputs.paper_command_verify_longport_app_secret_secret_name }} | |
| GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} | |
| # Set CLOUD_RUN_REGION per Environment so paper/HK/SG can target different regions. | |
| 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_RUN_ENV_SYNC_WAIT_FOR_COMMIT: ${{ vars.CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT }} | |
| CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} | |
| CLOUD_SCHEDULER_MAIN_TIME: ${{ vars.CLOUD_SCHEDULER_MAIN_TIME }} | |
| CLOUD_SCHEDULER_PROBE_TIME: ${{ vars.CLOUD_SCHEDULER_PROBE_TIME }} | |
| CLOUD_SCHEDULER_PRECHECK_TIME: ${{ vars.CLOUD_SCHEDULER_PRECHECK_TIME }} | |
| ACCOUNT_PREFIX: ${{ vars.ACCOUNT_PREFIX }} | |
| TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }} | |
| LONGPORT_APP_KEY_SECRET_NAME: ${{ vars.LONGPORT_APP_KEY_SECRET_NAME }} | |
| LONGPORT_APP_SECRET_SECRET_NAME: ${{ vars.LONGPORT_APP_SECRET_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_REGION: ${{ vars.ACCOUNT_REGION || matrix.target.default_account_region }} | |
| LONGPORT_SECRET_NAME: ${{ vars.LONGPORT_SECRET_NAME }} | |
| LONGBRIDGE_FEATURE_SNAPSHOT_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_PATH }} | |
| LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH }} | |
| LONGBRIDGE_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_FALLBACK_MODE }} | |
| LONGBRIDGE_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }} | |
| LONGBRIDGE_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MAX_STALE_DAYS }} | |
| LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_PATH }} | |
| LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON }} | |
| LONGBRIDGE_MARKET: ${{ vars.LONGBRIDGE_MARKET }} | |
| LONGBRIDGE_MARKET_CALENDAR: ${{ vars.LONGBRIDGE_MARKET_CALENDAR }} | |
| LONGBRIDGE_MARKET_TIMEZONE: ${{ vars.LONGBRIDGE_MARKET_TIMEZONE }} | |
| LONGBRIDGE_SYMBOL_SUFFIX: ${{ vars.LONGBRIDGE_SYMBOL_SUFFIX }} | |
| LONGBRIDGE_TRADING_CURRENCY: ${{ vars.LONGBRIDGE_TRADING_CURRENCY }} | |
| LONGBRIDGE_MIN_RESERVED_CASH_USD: ${{ vars.LONGBRIDGE_MIN_RESERVED_CASH_USD }} | |
| LONGBRIDGE_RESERVED_CASH_RATIO: ${{ vars.LONGBRIDGE_RESERVED_CASH_RATIO }} | |
| LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} | |
| LONGBRIDGE_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.LONGBRIDGE_MARKET_SIGNAL_HANDOFF_INDEX_URI }} | |
| LONGBRIDGE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.LONGBRIDGE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} | |
| LONGBRIDGE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.LONGBRIDGE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} | |
| LONGBRIDGE_MARKET_SIGNAL_CACHE_DIR: ${{ vars.LONGBRIDGE_MARKET_SIGNAL_CACHE_DIR }} | |
| LONGBRIDGE_MARKET_SIGNAL_REQUIRED: ${{ vars.LONGBRIDGE_MARKET_SIGNAL_REQUIRED }} | |
| LONGBRIDGE_MARKET_SIGNAL_FALLBACK_MODE: ${{ vars.LONGBRIDGE_MARKET_SIGNAL_FALLBACK_MODE }} | |
| LONGBRIDGE_MARKET_SIGNAL_MAX_STALE_DAYS: ${{ vars.LONGBRIDGE_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 }} | |
| LONGBRIDGE_CASH_ONLY_EXECUTION: ${{ vars.LONGBRIDGE_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 }} | |
| NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} | |
| EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} | |
| LIFECYCLE_PERFORMANCE_BUCKET: ${{ vars.LIFECYCLE_PERFORMANCE_BUCKET }} | |
| LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }} | |
| LONGBRIDGE_PHYSICAL_ACCOUNT_ID: ${{ vars.LONGBRIDGE_PHYSICAL_ACCOUNT_ID }} | |
| # Paper-only durable-command staging. These are intentionally opt-in; | |
| # a non-paper runtime rejects the producer even if configured. | |
| LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_ENABLED: ${{ vars.LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_ENABLED }} | |
| LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_CONSUMER_ENABLED: ${{ vars.LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_CONSUMER_ENABLED }} | |
| LONGBRIDGE_EXECUTION_COMMAND_CLOUD_URI: ${{ vars.LONGBRIDGE_EXECUTION_COMMAND_CLOUD_URI }} | |
| GLOBAL_TELEGRAM_CHAT_ID: ${{ secrets.GLOBAL_TELEGRAM_CHAT_ID }} | |
| TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} | |
| STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD }} | |
| STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_APP_TOKEN }} | |
| STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.STRATEGY_PLUGIN_ALERT_PUSH_ACCESS_TOKEN }} | |
| STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.TG_TOKEN }} | |
| steps: | |
| - name: Check whether Cloud Run automation is enabled | |
| id: config | |
| run: | | |
| set -euo pipefail | |
| write_github_output() { | |
| printf '%s\n' "$@" >> "$GITHUB_OUTPUT" | |
| } | |
| case "${WORKFLOW_TARGET:-configured}" in | |
| configured|hk-verify|paper-command-verify) ;; | |
| *) echo "Explicit configured targets require image-only-no-traffic mode." >&2; exit 1 ;; | |
| esac | |
| deploy_enabled=false | |
| env_sync_enabled=false | |
| scheduler_sync_enabled=false | |
| # QSL_ENABLE_CLOUD_RUN_AUTOMATION overrides ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION | |
| ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION="${QSL_ENABLE_CLOUD_RUN_AUTOMATION:-$ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION}" | |
| if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION:-}" != "true" ]; then | |
| write_github_output \ | |
| "deploy_enabled=false" \ | |
| "env_sync_enabled=false" \ | |
| "enabled=false" | |
| echo "Skipping ${DEPLOYMENT_LABEL} Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2 | |
| exit 0 | |
| fi | |
| if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] \ | |
| && [ "${WORKFLOW_TARGET:-configured}" = "hk-verify" ] \ | |
| && [ "${DEPLOYMENT_LABEL:-}" != "HK" ]; then | |
| write_github_output \ | |
| "deploy_enabled=false" \ | |
| "env_sync_enabled=false" \ | |
| "enabled=false" | |
| echo "Skipping ${DEPLOYMENT_LABEL} Cloud Run automation because hk-verify targets only the HK deployment." >&2 | |
| exit 0 | |
| fi | |
| if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] \ | |
| && [ "${WORKFLOW_TARGET:-configured}" = "paper-command-verify" ] \ | |
| && [ "${DEPLOYMENT_LABEL:-}" != "PAPER" ]; then | |
| write_github_output \ | |
| "deploy_enabled=false" \ | |
| "env_sync_enabled=false" \ | |
| "scheduler_sync_enabled=false" \ | |
| "enabled=false" | |
| echo "Skipping ${DEPLOYMENT_LABEL} Cloud Run automation because paper-command-verify targets only the PAPER deployment." >&2 | |
| exit 0 | |
| fi | |
| if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] \ | |
| && { [ "${WORKFLOW_TARGET:-configured}" = "hk-verify" ] || [ "${WORKFLOW_TARGET:-configured}" = "paper-command-verify" ]; }; then | |
| if [ "${INPUT_DEPLOY_IMAGE:-true}" = "true" ]; then | |
| deploy_enabled=true | |
| fi | |
| if [ "${INPUT_SYNC_ENV:-true}" = "true" ]; then | |
| env_sync_enabled=true | |
| fi | |
| else | |
| if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ]; then | |
| deploy_enabled=true | |
| fi | |
| if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ]; then | |
| env_sync_enabled=true | |
| fi | |
| fi | |
| if [ "${env_sync_enabled}" = "true" ] \ | |
| && [ "${WORKFLOW_TARGET:-configured}" != "paper-command-verify" ]; then | |
| scheduler_sync_enabled=true | |
| fi | |
| write_github_output \ | |
| "deploy_enabled=${deploy_enabled}" \ | |
| "env_sync_enabled=${env_sync_enabled}" \ | |
| "scheduler_sync_enabled=${scheduler_sync_enabled}" | |
| if [ "${deploy_enabled}" != "true" ] && [ "${env_sync_enabled}" != "true" ]; then | |
| write_github_output "enabled=false" | |
| echo "Skipping ${DEPLOYMENT_LABEL} Cloud Run automation because ENABLE_GITHUB_CLOUD_RUN_DEPLOY and ENABLE_GITHUB_ENV_SYNC are not true." >&2 | |
| exit 0 | |
| fi | |
| write_github_output "enabled=true" | |
| - name: Checkout repository | |
| if: steps.config.outputs.enabled == 'true' | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Apply HK verify-only dispatch defaults | |
| if: steps.config.outputs.enabled == 'true' && github.event_name == 'workflow_dispatch' && inputs.target == 'hk-verify' | |
| run: | | |
| set -euo pipefail | |
| service="${INPUT_CLOUD_RUN_SERVICE:-longbridge-quant-hk-verify-service}" | |
| region="${INPUT_CLOUD_RUN_REGION:-${CLOUD_RUN_REGION:-}}" | |
| runtime_target="$(SERVICE="${service}" python3 - <<'PY' | |
| import json | |
| import os | |
| print( | |
| json.dumps( | |
| { | |
| "platform_id": "longbridge", | |
| "strategy_profile": "hk_global_etf_tactical_rotation", | |
| "deployment_selector": "hk-verify", | |
| "account_scope": "hk-verify", | |
| "execution_mode": "paper", | |
| "dry_run_only": True, | |
| "service_name": os.environ["SERVICE"], | |
| }, | |
| separators=(",", ":"), | |
| ) | |
| ) | |
| PY | |
| )" | |
| { | |
| echo "CLOUD_RUN_SERVICE=${service}" | |
| echo "ACCOUNT_REGION=HK" | |
| echo "ACCOUNT_PREFIX=HK" | |
| echo "RUNTIME_TARGET_JSON=${runtime_target}" | |
| echo "LONGBRIDGE_DRY_RUN_ONLY=true" | |
| echo "LONGBRIDGE_MARKET=HK" | |
| echo "LONGBRIDGE_MARKET_CALENDAR=XHKG" | |
| echo "LONGBRIDGE_MARKET_TIMEZONE=Asia/Hong_Kong" | |
| echo "LONGBRIDGE_SYMBOL_SUFFIX=.HK" | |
| echo "LONGBRIDGE_TRADING_CURRENCY=HKD" | |
| } >> "$GITHUB_ENV" | |
| if [ -n "${region}" ]; then | |
| echo "CLOUD_RUN_REGION=${region}" >> "$GITHUB_ENV" | |
| fi | |
| if [ -n "${INPUT_LONGPORT_SECRET_NAME:-}" ]; then | |
| echo "LONGPORT_SECRET_NAME=${INPUT_LONGPORT_SECRET_NAME}" >> "$GITHUB_ENV" | |
| fi | |
| if [ -n "${INPUT_LONGPORT_APP_KEY_SECRET_NAME:-}" ]; then | |
| echo "LONGPORT_APP_KEY_SECRET_NAME=${INPUT_LONGPORT_APP_KEY_SECRET_NAME}" >> "$GITHUB_ENV" | |
| fi | |
| if [ -n "${INPUT_LONGPORT_APP_SECRET_SECRET_NAME:-}" ]; then | |
| echo "LONGPORT_APP_SECRET_SECRET_NAME=${INPUT_LONGPORT_APP_SECRET_SECRET_NAME}" >> "$GITHUB_ENV" | |
| fi | |
| if [ "${INPUT_DEPLOY_IMAGE:-true}" != "true" ]; then | |
| echo "CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT=false" >> "$GITHUB_ENV" | |
| fi | |
| - name: Apply isolated paper-command verification defaults | |
| if: steps.config.outputs.enabled == 'true' && github.event_name == 'workflow_dispatch' && inputs.target == 'paper-command-verify' | |
| run: | | |
| set -euo pipefail | |
| service="${INPUT_PAPER_COMMAND_VERIFY_SERVICE:-longbridge-quant-paper-command-verify-service}" | |
| profile="${INPUT_PAPER_COMMAND_VERIFY_STRATEGY_PROFILE:-}" | |
| command_uri="${INPUT_PAPER_COMMAND_VERIFY_EXECUTION_COMMAND_CLOUD_URI:-}" | |
| release_json="${INPUT_PAPER_COMMAND_VERIFY_STRATEGY_RELEASE_JSON:-}" | |
| if [ -z "${profile}" ] || [ -z "${command_uri}" ] || [ -z "${release_json}" ]; then | |
| echo "paper-command-verify requires a strategy profile, a dedicated command GCS URI, and a complete strategy release identity." >&2 | |
| exit 1 | |
| fi | |
| case "${command_uri}" in | |
| gs://*) ;; | |
| *) | |
| echo "paper-command-verify command storage must be a gs:// URI." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| report_uri="${EXECUTION_REPORT_GCS_URI%/}" | |
| command_uri_trimmed="${command_uri%/}" | |
| if [ -n "${report_uri}" ] \ | |
| && { [ "${command_uri_trimmed}" = "${report_uri}" ] || [[ "${command_uri_trimmed}" == "${report_uri}/"* ]]; }; then | |
| echo "paper-command-verify command storage must not reuse the execution report URI or its prefix." >&2 | |
| exit 1 | |
| fi | |
| runtime_target="$(SERVICE="${service}" PROFILE="${profile}" RELEASE_JSON="${release_json}" python3 - <<'PY' | |
| import json | |
| import os | |
| release = json.loads(os.environ["RELEASE_JSON"]) | |
| required = { | |
| "release_id", | |
| "manifest_sha256", | |
| "strategy_revision", | |
| "config_sha256", | |
| "risk_policy_sha256", | |
| "evidence_sha256", | |
| "plugin_bundle_sha256", | |
| "effective_session", | |
| } | |
| if not isinstance(release, dict) or required.difference(release): | |
| raise SystemExit("paper-command-verify strategy release identity is incomplete") | |
| print( | |
| json.dumps( | |
| { | |
| "platform_id": "longbridge", | |
| "strategy_profile": os.environ["PROFILE"], | |
| "deployment_selector": "paper-command-verify", | |
| "account_scope": "paper-command-verify", | |
| "execution_mode": "paper", | |
| "dry_run_only": True, | |
| "service_name": os.environ["SERVICE"], | |
| "strategy_release": release, | |
| }, | |
| separators=(",", ":"), | |
| ) | |
| ) | |
| PY | |
| )" | |
| { | |
| echo "CLOUD_RUN_SERVICE=${service}" | |
| echo "ACCOUNT_REGION=PAPER" | |
| echo "ACCOUNT_PREFIX=PAPER_VERIFY" | |
| echo "STRATEGY_PROFILE=${profile}" | |
| echo "RUNTIME_TARGET_JSON=${runtime_target}" | |
| echo "RUNTIME_TARGET_ENABLED=false" | |
| echo "LONGBRIDGE_DRY_RUN_ONLY=true" | |
| echo "LONGBRIDGE_MARKET=US" | |
| echo "LONGBRIDGE_MARKET_CALENDAR=NYSE" | |
| echo "LONGBRIDGE_MARKET_TIMEZONE=America/New_York" | |
| echo "LONGBRIDGE_SYMBOL_SUFFIX=.US" | |
| echo "LONGBRIDGE_TRADING_CURRENCY=USD" | |
| echo "LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_ENABLED=true" | |
| echo "LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_CONSUMER_ENABLED=true" | |
| echo "LONGBRIDGE_EXECUTION_COMMAND_CLOUD_URI=${command_uri_trimmed}" | |
| echo "LONGPORT_SECRET_NAME=${INPUT_PAPER_COMMAND_VERIFY_LONGPORT_SECRET_NAME}" | |
| echo "LONGPORT_APP_KEY_SECRET_NAME=${INPUT_PAPER_COMMAND_VERIFY_LONGPORT_APP_KEY_SECRET_NAME}" | |
| echo "LONGPORT_APP_SECRET_SECRET_NAME=${INPUT_PAPER_COMMAND_VERIFY_LONGPORT_APP_SECRET_SECRET_NAME}" | |
| } >> "$GITHUB_ENV" | |
| if [ "${INPUT_DEPLOY_IMAGE:-true}" != "true" ]; then | |
| echo "CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT=false" >> "$GITHUB_ENV" | |
| fi | |
| - name: Set up Python for strategy requirement resolution | |
| if: steps.config.outputs.enabled == 'true' | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Install strategy status dependencies | |
| if: steps.config.outputs.enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| python -m pip install --upgrade pip uv | |
| uv sync --frozen --no-dev | |
| - name: Resolve Cloud Run sync targets | |
| id: strategy_requirements | |
| env: | |
| GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_CLOUD_PROJECT || env.GCP_PROJECT_ID }} | |
| 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 | |
| ACCOUNT_PREFIX | |
| LONGPORT_SECRET_NAME | |
| NOTIFY_LANG | |
| GLOBAL_TELEGRAM_CHAT_ID | |
| ) | |
| 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 "${LONGPORT_APP_KEY_SECRET_NAME:-}" ]; then | |
| missing_vars+=("LONGPORT_APP_KEY_SECRET_NAME") | |
| fi | |
| if [ -z "${LONGPORT_APP_SECRET_SECRET_NAME:-}" ]; then | |
| missing_vars+=("LONGPORT_APP_SECRET_SECRET_NAME") | |
| 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 "${DEPLOYMENT_LABEL} Cloud Run env sync is enabled, but these values are missing:" >&2 | |
| echo " - Set CLOUD_RUN_REGION on the ${GITHUB_ENVIRONMENT_NAME} Environment so each service can target its own region." >&2 | |
| echo " - Set LONGPORT_APP_KEY_SECRET_NAME and LONGPORT_APP_SECRET_SECRET_NAME on the ${GITHUB_ENVIRONMENT_NAME} Environment so credentials do not fall back to shared defaults." >&2 | |
| printf ' - %s\n' "${missing_vars[@]}" >&2 | |
| exit 1 | |
| fi | |
| - name: Validate deploy inputs | |
| if: steps.config.outputs.deploy_enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| 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 "${DEPLOYMENT_LABEL} 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: Verify deployed runtime target admission before traffic shift | |
| if: steps.config.outputs.deploy_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: Download constraints | |
| if: steps.config.outputs.deploy_enabled == 'true' | |
| run: curl -sL https://raw.githubusercontent.com/QuantStrategyLab/QuantPlatformKit/main/constraints.txt -o constraints.txt | |
| - name: Build, push, and deploy Cloud Run image | |
| if: steps.config.outputs.deploy_enabled == 'true' | |
| 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}/longbridgeplatform/${CLOUD_RUN_SERVICE}" | |
| image="${image_repo}:${GITHUB_SHA}" | |
| deployment_label="$(printf '%s' "${DEPLOYMENT_LABEL}" | tr '[:upper:]' '[:lower:]')" | |
| gcloud auth configure-docker "${artifact_registry_hostname}" --quiet | |
| git archive HEAD | 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}" \ | |
| --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},deployment-label=${deployment_label}" \ | |
| --quiet | |
| - name: Wait for Cloud Run deployment of current commit | |
| if: steps.config.outputs.deploy_enabled == 'true' && steps.config.outputs.env_sync_enabled == 'true' | |
| run: | | |
| set -euo pipefail | |
| case "${CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT:-true}" in | |
| false|False|FALSE|0|no|No|NO|off|Off|OFF) | |
| echo "Skipping Cloud Run commit wait because CLOUD_RUN_ENV_SYNC_WAIT_FOR_COMMIT is disabled." | |
| exit 0 | |
| ;; | |
| esac | |
| target_sha="${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"]) | |
| service = os.environ.get("CLOUD_RUN_SERVICE", "").strip() | |
| targets = plan.get("targets") or [] | |
| target = next( | |
| (item for item in targets if str(item.get("service_name") or "").strip() == service), | |
| 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"]) | |
| service = os.environ.get("CLOUD_RUN_SERVICE", "").strip() | |
| targets = plan.get("targets") or [] | |
| target = next( | |
| (item for item in targets if str(item.get("service_name") or "").strip() == service), | |
| 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[@]}") | |
| secret_pairs=() | |
| remove_env_vars=("${target_remove_env_vars[@]}") | |
| remove_env_vars+=( | |
| "TELEGRAM_CHAT_ID" | |
| "SERVICE_NAME" | |
| "LONGBRIDGE_FRACTIONAL_SHARES_ENABLED" | |
| "LONGBRIDGE_ORDER_QUANTITY_STEP" | |
| "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD" | |
| # The shared durable-command consumer is paper-only. These stale | |
| # variables must never remain on a normal live service: a legacy | |
| # deployment otherwise attempts to enable a paper producer and | |
| # blocks the whole live rebalance before it reaches the broker. | |
| "LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_ENABLED" | |
| "LONGBRIDGE_EXECUTION_COMMAND_CLOUD_URI" | |
| "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 "${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 | |
| secret_pairs+=("LONGPORT_APP_KEY=${LONGPORT_APP_KEY_SECRET_NAME}:latest") | |
| remove_env_vars+=("LONGPORT_APP_KEY") | |
| secret_pairs+=("LONGPORT_APP_SECRET=${LONGPORT_APP_SECRET_SECRET_NAME}:latest") | |
| remove_env_vars+=("LONGPORT_APP_SECRET") | |
| if [ -n "${LONGPORT_SECRET_NAME:-}" ]; then | |
| env_pairs+=("LONGPORT_SECRET_NAME=${LONGPORT_SECRET_NAME}") | |
| else | |
| remove_env_vars+=("LONGPORT_SECRET_NAME") | |
| fi | |
| if [ -n "${ACCOUNT_REGION:-}" ]; then | |
| env_pairs+=("ACCOUNT_REGION=${ACCOUNT_REGION}") | |
| else | |
| remove_env_vars+=("ACCOUNT_REGION") | |
| 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 | |
| # gcloud rejects duplicate names in --remove-env-vars. | |
| mapfile -t remove_env_vars < <(printf '%s\n' "${remove_env_vars[@]}" | awk 'NF && !seen[$0]++') | |
| 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: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| STRATEGY_PLUGIN_MOUNT_ENV_NAMES: LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/verify_cloud_run_strategy_plugin_mounts.py | |
| - name: Reconcile Cloud Run traffic | |
| if: steps.config.outputs.env_sync_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/reconcile_cloud_runtime.py --platform longbridge --ensure-latest-traffic --service "${CLOUD_RUN_SERVICE}" | |
| - name: Sync Cloud Scheduler schedule | |
| if: steps.config.outputs.scheduler_sync_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| run: | | |
| set -euo pipefail | |
| scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" | |
| if [ -z "${scheduler_location}" ]; then | |
| echo "Cloud Scheduler schedule sync requires CLOUD_RUN_REGION or CLOUD_SCHEDULER_LOCATION." >&2 | |
| exit 1 | |
| fi | |
| mapfile -t scheduler_config < <(python - <<'PY' | |
| import json | |
| import os | |
| plan = json.loads(os.environ["SYNC_PLAN_JSON"]) | |
| service = os.environ.get("CLOUD_RUN_SERVICE", "").strip() | |
| targets = plan.get("targets") or [] | |
| target = next( | |
| (item for item in targets if str(item.get("service_name") or "").strip() == service), | |
| targets[0], | |
| ) | |
| scheduler = target.get("scheduler") or {} | |
| target_env = target.get("env") or {} | |
| print(str(scheduler.get("timezone") or os.environ.get("LONGBRIDGE_MARKET_TIMEZONE", "")).strip() or "America/New_York") | |
| print(str(scheduler.get("main_time") or os.environ.get("CLOUD_SCHEDULER_MAIN_TIME", "").strip() or "45 15")) | |
| print(str(scheduler.get("probe_time") or os.environ.get("CLOUD_SCHEDULER_PROBE_TIME", "").strip() or "35 9,15")) | |
| print(str(scheduler.get("precheck_time") or os.environ.get("CLOUD_SCHEDULER_PRECHECK_TIME", "").strip() or "45 9")) | |
| print(str(target_env.get("RUNTIME_TARGET_ENABLED") or "true").strip().lower()) | |
| PY | |
| ) | |
| market_timezone="${scheduler_config[0]}" | |
| main_time="${scheduler_config[1]}" | |
| probe_time="${scheduler_config[2]}" | |
| precheck_time="${scheduler_config[3]}" | |
| runtime_target_enabled="${scheduler_config[4]}" | |
| # Keep probe parsed for runtime-target schema parity. | |
| : "${probe_time}" | |
| service_url="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --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: | |
| if current_fields[2] != "*" and current_fields[4] != "*": | |
| raise SystemExit( | |
| "Cloud Scheduler schedule cannot constrain both day-of-month and day-of-week" | |
| ) | |
| print(" ".join([*time_fields, *current_fields[2:]])) | |
| else: | |
| raise SystemExit( | |
| 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 | |
| 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 | |
| )" | |
| 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}" \ | |
| --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 | |
| probe_job_name="${CLOUD_RUN_SERVICE}-probe-scheduler" | |
| probe_uri="${service_url}/probe" | |
| if gcloud scheduler jobs describe "${probe_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" >/dev/null 2>&1; then | |
| echo "Updating Cloud Scheduler probe ${probe_job_name} to ${desired_probe_schedule}." | |
| gcloud scheduler jobs update http "${probe_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${probe_uri}" \ | |
| --schedule="${desired_probe_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| else | |
| echo "Creating Cloud Scheduler probe ${probe_job_name} at ${desired_probe_schedule}." | |
| gcloud scheduler jobs create http "${probe_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --uri="${probe_uri}" \ | |
| --schedule="${desired_probe_schedule}" \ | |
| --time-zone="${market_timezone}" \ | |
| --http-method=POST \ | |
| --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ | |
| --oidc-token-audience="${service_url}" \ | |
| --attempt-deadline=180s \ | |
| --max-retry-attempts=0 \ | |
| --max-retry-duration=0s \ | |
| --quiet | |
| fi | |
| precheck_job_name="${CLOUD_RUN_SERVICE}-precheck-scheduler" | |
| precheck_uri="${service_url}/dry-run" | |
| if gcloud scheduler jobs describe "${precheck_job_name}" \ | |
| --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=("${job_name}" "${probe_job_name}" "${precheck_job_name}") | |
| for managed_job_name in "${managed_scheduler_jobs[@]}"; do | |
| managed_job_state="$(gcloud scheduler jobs describe "${managed_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --format='value(state)')" | |
| case "${runtime_target_enabled}" in | |
| 1|true|yes|on) | |
| if [ "${managed_job_state}" = "PAUSED" ]; then | |
| echo "Resuming Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is enabled." | |
| gcloud scheduler jobs resume "${managed_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --quiet | |
| fi | |
| ;; | |
| *) | |
| if [ "${managed_job_state}" != "PAUSED" ]; then | |
| echo "Pausing Cloud Scheduler job ${managed_job_name} because ${CLOUD_RUN_SERVICE} is disabled." | |
| gcloud scheduler jobs pause "${managed_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --quiet | |
| fi | |
| ;; | |
| esac | |
| done | |
| - name: Reconcile legacy Cloud Scheduler jobs | |
| if: steps.config.outputs.scheduler_sync_enabled == 'true' | |
| env: | |
| SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/reconcile_cloud_runtime.py --platform longbridge --delete-legacy-schedulers --service "${CLOUD_RUN_SERVICE}" | |
| - name: Prune old Cloud Run revisions | |
| if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_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 | |
| if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' | |
| 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}/longbridgeplatform/${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}" | |
| cleanup-shared-monitor: | |
| name: Retire shared monitor dispatcher | |
| needs: sync | |
| if: needs.sync.result == 'success' && inputs.target == 'configured' && inputs.deployment_mode != 'image-only-no-traffic' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| environment: longbridge-sg | |
| permissions: | |
| contents: read | |
| id-token: write | |
| env: | |
| CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} | |
| CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} | |
| CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} | |
| steps: | |
| - name: Authenticate to Google Cloud | |
| uses: google-github-actions/auth@v3 | |
| with: | |
| workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} | |
| service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} | |
| - name: Set up gcloud | |
| uses: google-github-actions/setup-gcloud@v3 | |
| with: | |
| project_id: ${{ env.GCP_PROJECT_ID }} | |
| version: ">= 416.0.0" | |
| - name: Verify per-service scheduler replacements | |
| id: replacements | |
| run: | | |
| set -euo pipefail | |
| replacements_ready="true" | |
| mapfile -t configured_targets < <(python3 - <<'PY' | |
| import json | |
| import os | |
| payload = json.loads(os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "{}") | |
| if isinstance(payload, list): | |
| targets = payload | |
| elif isinstance(payload, dict): | |
| targets = payload.get("targets") or [] | |
| else: | |
| targets = [] | |
| for target in targets: | |
| if not isinstance(target, dict): | |
| continue | |
| service_name = str(target.get("service_name") or target.get("service") or "").strip() | |
| scheduler = target.get("scheduler") if isinstance(target.get("scheduler"), dict) else {} | |
| location = str( | |
| target.get("scheduler_location") | |
| or scheduler.get("location") | |
| or target.get("region") | |
| or target.get("cloud_run_region") | |
| or os.environ.get("CLOUD_SCHEDULER_LOCATION") | |
| or os.environ.get("CLOUD_RUN_REGION") | |
| or "" | |
| ).strip() | |
| if service_name: | |
| print(f"{service_name}\t{location}") | |
| PY | |
| ) | |
| if [ "${#configured_targets[@]}" -eq 0 ]; then | |
| echo "No configured LongBridge targets; keeping the shared monitor dispatcher." >&2 | |
| replacements_ready="false" | |
| fi | |
| for configured_target in "${configured_targets[@]}"; do | |
| IFS=$'\t' read -r service_name scheduler_location <<< "${configured_target}" | |
| if [ -z "${scheduler_location}" ]; then | |
| echo "No scheduler location for ${service_name}; keeping the shared monitor dispatcher." >&2 | |
| replacements_ready="false" | |
| continue | |
| fi | |
| replacement_jobs=("${service_name}-probe-scheduler" "${service_name}-precheck-scheduler") | |
| for replacement_job in "${replacement_jobs[@]}"; do | |
| if ! gcloud scheduler jobs describe "${replacement_job}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" >/dev/null 2>&1; then | |
| echo "Replacement ${replacement_job} is not ready in ${scheduler_location}." >&2 | |
| replacements_ready="false" | |
| fi | |
| done | |
| done | |
| echo "ready=${replacements_ready}" >> "$GITHUB_OUTPUT" | |
| - name: Delete obsolete shared monitor dispatcher | |
| if: steps.replacements.outputs.ready == 'true' | |
| run: | | |
| set -euo pipefail | |
| scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" | |
| if [ -z "${scheduler_location}" ]; then | |
| echo "Shared monitor dispatcher cleanup requires a scheduler location." >&2 | |
| exit 1 | |
| fi | |
| monitor_job_name="longbridge-monitor-dispatcher-scheduler" | |
| if gcloud scheduler jobs describe "${monitor_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" >/dev/null 2>&1; then | |
| echo "Deleting obsolete shared monitor dispatcher ${monitor_job_name}." | |
| gcloud scheduler jobs delete "${monitor_job_name}" \ | |
| --project="${GCP_PROJECT_ID}" \ | |
| --location="${scheduler_location}" \ | |
| --quiet | |
| fi |