Promote (test / prod) #11
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: Promote (test / prod) | |
| # Promotion across the security boundary: | |
| # - Manual workflow_dispatch -> TEST: build the canonical image from the chosen ref into TEST's | |
| # own Artifact Registry and deploy it to test. (test doubles as UAT.) This is the "one | |
| # rebuild into test" — dev's image is never promoted, since dev is outside the boundary. | |
| # - Push a v* tag -> PROD: deploy the exact image TEST already validated, by digest, | |
| # referencing TEST's registry directly (no rebuild, no copy). Gated by the `prod` | |
| # Environment's required-reviewer rule. prod's deployer SA + Cloud Run service agent must | |
| # have read on TEST's Artifact Registry. The tag must equal "v" + pyproject.toml's version | |
| # (checked below): no rebuild happens here, so the version is whatever test baked in. | |
| # | |
| # For the prod path, test's registry coordinates come from repo-level Variables (test's project | |
| # isn't in prod's Environment): TEST_PROJECT_ID (required), TEST_REGION / TEST_AR_REPO (optional). | |
| # The image for the tagged commit must already exist in test (i.e. it was promoted to test first). | |
| # See dev/deployment.md. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| ref: | |
| description: "Git ref (branch / tag / SHA) to build into test" | |
| type: string | |
| required: true | |
| default: main | |
| target: | |
| description: "Which service(s) to deploy" | |
| type: choice | |
| options: [rest, mcp, both] | |
| default: both | |
| push: | |
| tags: ["v*"] | |
| permissions: | |
| contents: read | |
| jobs: | |
| # ---------- TEST path (manual dispatch): build canonical image into test, then deploy ---------- | |
| build-test: | |
| if: ${{ github.event_name == 'workflow_dispatch' }} | |
| runs-on: ubuntu-latest | |
| environment: test | |
| outputs: | |
| build_label: ${{ steps.vars.outputs.sha }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ inputs.ref }} | |
| - name: Resolve short SHA | |
| id: vars | |
| run: echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" | |
| - name: Authenticate to Google Cloud | |
| uses: google-github-actions/auth@v3 | |
| with: | |
| credentials_json: ${{ secrets.GCP_SA_KEY }} | |
| - name: Set up gcloud | |
| uses: google-github-actions/setup-gcloud@v3 | |
| with: | |
| project_id: ${{ secrets.GCP_PROJECT_ID }} | |
| - name: Build & push canonical image (Cloud Build) | |
| env: | |
| PROJECT: ${{ secrets.GCP_PROJECT_ID }} | |
| REGION: ${{ vars.REGION || 'us-central1' }} | |
| AR_REPO: ${{ vars.AR_REPO || 'idc' }} | |
| SHA: ${{ steps.vars.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${AR_REPO}/idc-api-v3:${SHA}" | |
| gcloud builds submit --config dev/cloudbuild.yaml --substitutions _IMAGE="$IMAGE" | |
| deploy-test: | |
| if: ${{ github.event_name == 'workflow_dispatch' }} | |
| needs: build-test | |
| uses: ./.github/workflows/deploy.yml | |
| with: | |
| environment: test | |
| build_label: ${{ needs.build-test.outputs.build_label }} | |
| target: ${{ inputs.target }} | |
| secrets: inherit | |
| # ---------- PROD path (v* tag): deploy test's digest to prod, no rebuild ---------- | |
| resolve-prod: | |
| if: ${{ github.event_name == 'push' }} | |
| runs-on: ubuntu-latest | |
| outputs: | |
| build_label: ${{ steps.r.outputs.sha }} | |
| source_image: ${{ steps.r.outputs.image }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # The tag names the release, but the version the running server reports comes from | |
| # pyproject.toml, baked into the image at build time. Prod redeploys test's existing digest | |
| # and never rebuilds, so a tag that disagrees with the packaged version would deploy fine | |
| # and quietly serve the wrong version at /v3/version. Fail here, before the reviewer gate. | |
| - name: Verify the tag matches the packaged version | |
| env: | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| VERSION=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])') | |
| if [ "${TAG#v}" != "$VERSION" ]; then | |
| echo "::error::Tag '${TAG}' does not match pyproject.toml version '${VERSION}'. The version is baked in when test builds the image, so bump pyproject.toml, promote that commit to test, then tag it. See CONTRIBUTING.md -> Releasing." | |
| exit 1 | |
| fi | |
| echo "Tag ${TAG} matches packaged version ${VERSION}." | |
| - id: r | |
| env: | |
| TEST_PROJECT: ${{ vars.TEST_PROJECT_ID }} | |
| TEST_REGION: ${{ vars.TEST_REGION || vars.REGION || 'us-central1' }} | |
| TEST_AR_REPO: ${{ vars.TEST_AR_REPO || vars.AR_REPO || 'idc' }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${TEST_PROJECT:-}" ]; then | |
| echo "::error::Repo variable TEST_PROJECT_ID is not set — prod deploys test's image and needs to know test's project." | |
| exit 1 | |
| fi | |
| SHA=${GITHUB_SHA:0:7} | |
| { | |
| echo "sha=$SHA" | |
| echo "image=${TEST_REGION}-docker.pkg.dev/${TEST_PROJECT}/${TEST_AR_REPO}/idc-api-v3:${SHA}" | |
| } >> "$GITHUB_OUTPUT" | |
| deploy-prod: | |
| if: ${{ github.event_name == 'push' }} | |
| needs: resolve-prod | |
| uses: ./.github/workflows/deploy.yml | |
| with: | |
| environment: prod | |
| build_label: ${{ needs.resolve-prod.outputs.build_label }} | |
| source_image: ${{ needs.resolve-prod.outputs.source_image }} | |
| target: both | |
| secrets: inherit |