Skip to content

Deploy Cloud Run

Deploy Cloud Run #580

name: Deploy Cloud Run
on:
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
- paper-command-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
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:
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'
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 }}
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: ${{ 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 }}
# 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: ${{ 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
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.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 --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}" \
--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.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' && 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