diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index e2dd038..3e5528f 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -153,9 +153,32 @@ concurrency: cancel-in-progress: false jobs: + approve-live-traffic: + name: Approve LongBridge production traffic + if: >- + github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && + github.run_attempt == 1 && + ((inputs.deployment_mode == 'legacy' && inputs.shift_traffic_to_commit) || + (inputs.deployment_mode == 'sg-serving-snapshot' && inputs.target == 'SG' && inputs.account_snapshot_enabled == 'true') || + ((inputs.deployment_mode == 'image-only-no-traffic' || inputs.deployment_mode == 'image-only-notification-overlay') && + inputs.target == 'HK' && inputs.account_snapshot_enabled == 'true')) + environment: longbridge-live-approval + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: {} + steps: + - name: Record approval + run: echo "Approved the production traffic change for this dispatch." >> "$GITHUB_STEP_SUMMARY" + image-only-no-traffic: name: Stage ${{ inputs.target }} image without traffic - if: github.event_name == 'workflow_dispatch' && (inputs.deployment_mode == 'image-only-no-traffic' || inputs.deployment_mode == 'image-only-notification-overlay') + needs: approve-live-traffic + if: >- + !cancelled() && github.event_name == 'workflow_dispatch' && + (inputs.deployment_mode == 'image-only-no-traffic' || inputs.deployment_mode == 'image-only-notification-overlay') && + (needs.approve-live-traffic.result == 'success' || + (needs.approve-live-traffic.result == 'skipped' && + !(inputs.target == 'HK' && inputs.account_snapshot_enabled == 'true'))) runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -652,10 +675,14 @@ jobs: python3 scripts/render_runtime_target_matrix.py --profile sync --github-output sync: - needs: resolve-matrix + needs: [resolve-matrix, approve-live-traffic] name: Deploy / Sync ${{ matrix.target.label }} Cloud Run # Production deployment and environment sync require an explicit operator dispatch. - if: github.event_name == 'workflow_dispatch' && inputs.deployment_mode == 'legacy' + if: >- + !cancelled() && github.event_name == 'workflow_dispatch' && + inputs.deployment_mode == 'legacy' && needs.resolve-matrix.result == 'success' && + (needs.approve-live-traffic.result == 'success' || + (needs.approve-live-traffic.result == 'skipped' && !inputs.shift_traffic_to_commit)) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -2312,8 +2339,9 @@ jobs: sg-serving-snapshot: name: Enable only the SG read-only snapshot + needs: approve-live-traffic if: >- - github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && + needs.approve-live-traffic.result == 'success' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.run_attempt == 1 && inputs.deployment_mode == 'sg-serving-snapshot' && inputs.target == 'SG' && inputs.approved_ref == 'main' && inputs.account_snapshot_enabled == 'true' && diff --git a/docs/runtime-write-isolation.md b/docs/runtime-write-isolation.md index 4454e55..34a267f 100644 --- a/docs/runtime-write-isolation.md +++ b/docs/runtime-write-isolation.md @@ -317,3 +317,13 @@ staging; this mode neither changes configuration nor moves traffic. + + +## Production traffic approval + +LongBridge traffic promotion uses the `longbridge-live-approval` Environment +on `main`. This covers explicit `shift_traffic_to_commit` and the HK and SG snapshot +modes that promote a serving revision. Staging without traffic and +standalone readback skip this approval. The existing runtime-target, +revision and readback checks still run before or after the traffic change +as appropriate. diff --git a/tests/test_runtime_target_matrix.py b/tests/test_runtime_target_matrix.py index d24e86e..1dad22a 100644 --- a/tests/test_runtime_target_matrix.py +++ b/tests/test_runtime_target_matrix.py @@ -280,7 +280,8 @@ def test_monitor_and_deploy_workflows_consume_validated_matrix_output(): assert 'render_runtime_target_matrix.py --profile heartbeat --target "$MATRIX_TARGET" --github-output' in text else: assert f"render_runtime_target_matrix.py --profile {profile} --github-output" in text - assert "needs: resolve-matrix" in text + dependency = "needs: [resolve-matrix, approve-live-traffic]" if profile == "sync" else "needs: resolve-matrix" + assert dependency in text assert "matrix: ${{ fromJSON(needs.resolve-matrix.outputs.matrix) }}" in text assert "vars.RUNTIME_TARGET_ENABLED" in text # Hardcoded PAPER/HK/SG matrix blocks must not remain in dynamic consumers. diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 428d43c..ad32b08 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -10,7 +10,7 @@ grep -Fq 'name: Deploy / Sync ${{ matrix.target.label }} Cloud Run' "$workflow_f grep -Fq 'fail-fast: false' "$workflow_file" grep -Fq 'resolve-matrix:' "$workflow_file" grep -Fq 'render_runtime_target_matrix.py --profile sync --github-output' "$workflow_file" -grep -Fq 'needs: resolve-matrix' "$workflow_file" +grep -Fq 'needs: [resolve-matrix, approve-live-traffic]' "$workflow_file" grep -Fq 'matrix: ${{ fromJSON(needs.resolve-matrix.outputs.matrix) }}' "$workflow_file" grep -Fq "inputs.target == 'PAPER' && 'longbridge-paper'" "$workflow_file" grep -Fq "inputs.target == 'HK' && 'longbridge-hk'" "$workflow_file" @@ -448,7 +448,12 @@ assert "name: Mask WIF identity components" in masking assert "if: matrix.target.environment == 'longbridge-paper' && steps.config.outputs.enabled == 'true'" in masking for section in (legacy, cleanup): assert "inputs.deployment_mode == 'legacy'" in section.split(" steps:", 1)[0] -assert "needs: resolve-matrix" in legacy.split(" steps:", 1)[0] +assert "needs: [resolve-matrix, approve-live-traffic]" in legacy.split(" steps:", 1)[0] +assert "needs.approve-live-traffic.result == 'success'" in legacy.split(" steps:", 1)[0] +assert "needs.approve-live-traffic.result == 'success'" in job.split(" steps:", 1)[0] +sg_snapshot = workflow.split("\n sg-serving-snapshot:\n", 1)[1] +assert "needs: approve-live-traffic" in sg_snapshot.split(" steps:", 1)[0] +assert "needs.approve-live-traffic.result == 'success'" in sg_snapshot.split(" steps:", 1)[0] assert job.index("name: Validate image-only dispatch") < job.index("uses: google-github-actions/auth@") assert job.index("name: Verify exact image-only source") < job.index("uses: google-github-actions/auth@") # Only the service selector, protected WIF identity and immutable overlay inputs are allowed;