Deploy Cloud Run #560
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: | |
| inputs: | |
| target: | |
| description: "Deployment target to run. Use hk-verify for an isolated HK dry-run Cloud Run service." | |
| required: true | |
| type: choice | |
| default: configured | |
| options: | |
| - configured | |
| - hk-verify | |
| 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 | |
| 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: | |
| sync: | |
| name: Deploy / Sync ${{ matrix.target.label }} 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 | |
| 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 }} | |
| 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 }} | |
| 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: ${{ vars.CLOUD_RUN_SERVICE }} | |
| CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.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 }} | |
| RUNTIME_TARGET_JSON: ${{ vars.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 }} | |
| LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }} | |
| GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.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" | |
| } | |
| 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 ${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}" = "hk-verify" ]; then | |
| if [ "${INPUT_DEPLOY_IMAGE:-true}" = "true" ]; then | |
| deploy_enabled=true | |
| fi | |
| if [ "${INPUT_SYNC_ENV:-true}" = "true" ]; then | |
| env_sync_enabled=true | |
| fi | |
| else | |
| if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ]; then | |
| deploy_enabled=true | |
| fi | |
| if [ "${ENABLE_GITHUB_ENV_SYNC:-}" = "true" ]; then | |
| env_sync_enabled=true | |
| fi | |
| 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 ${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.event_name == 'workflow_run' && github.event.workflow_run.head_sha || 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: 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 | |
| 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: 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 | |
| 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},deployment-label=${deployment_label}" \ | |
| --quiet | |
| - name: Wait for Cloud Run deployment of current commit | |
| if: 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" | |
| "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_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 | |
| - 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"]) | |
| 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}" \ | |
| --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.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 --delete-legacy-schedulers | |
| - 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 | |
| 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}" | |
| 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' && | |
| ( | |
| github.event_name == 'workflow_run' || | |
| (github.event_name == 'workflow_dispatch' && inputs.target == 'configured') | |
| ) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| environment: longbridge-sg | |
| permissions: | |
| contents: read | |
| id-token: write | |
| env: | |
| CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }} | |
| CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} | |
| CLOUD_SCHEDULER_LOCATION: ${{ vars.CLOUD_SCHEDULER_LOCATION }} | |
| steps: | |
| - name: Authenticate to Google Cloud | |
| uses: google-github-actions/auth@v3 | |
| with: | |
| workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }} | |
| service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} | |
| - name: Set up gcloud | |
| uses: google-github-actions/setup-gcloud@v3 | |
| with: | |
| project_id: ${{ env.GCP_PROJECT_ID }} | |
| version: ">= 416.0.0" | |
| - name: Verify per-service scheduler replacements | |
| id: replacements | |
| run: | | |
| set -euo pipefail | |
| replacements_ready="true" | |
| mapfile -t configured_targets < <(python3 - <<'PY' | |
| import json | |
| import os | |
| payload = json.loads(os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "{}") | |
| 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 |