# .github/workflows/gcp-deploy.yml — generated by
# `autumn release init --target gcp-cloud-run`
#
# Opt-in: this workflow only runs when you push a `v*` tag (or trigger it
# manually), and every credential comes from repo/environment secrets that
# don't exist until you add them — it never fires unauthenticated.
#
# Required repository secrets (Settings -> Secrets and variables -> Actions):
# GCP_WORKLOAD_IDENTITY_PROVIDER — full resource name of a Workload
# Identity Federation provider trusting GitHub's OIDC issuer for this
# repo (see google-github-actions/auth's WIF setup guide). No
# long-lived service account key is needed: `permissions.id-token:
# write` below plus this provider is enough for OIDC login.
# GCP_DEPLOYER_SERVICE_ACCOUNT — email of a CI deployer service account
# (distinct from the app's OWN runtime service account main.tf
# provisions) granted `roles/artifactregistry.writer` on the
# repository, `roles/run.developer` (deploy the service and manage the
# migration job) at the project or resource scope, and
# `roles/iam.serviceAccountUser` on the runtime service account
# (`terraform output service_account_email`) — Cloud Run requires the
# deployer to be able to act as whatever service account a
# service/job's revisions run as.
#
# Required repository variables (same Settings page, "Variables" tab — not
# secret, just config the job needs), all `terraform output` values — never
# hand-typed:
# GCP_PROJECT_ID — the project you deployed into.
# GCP_REGION — output of `terraform output region`.
# GCP_ARTIFACT_REGISTRY_URL — output of `terraform output artifact_registry_repository_url`.
# GCP_SERVICE_NAME — output of `terraform output service_name`.
# GCP_MIGRATE_JOB_NAME — output of `terraform output migrate_job_name`.
#
# These repository variables are a snapshot, not a live link to Terraform
# state: if `app_name` (or `region`) is edited in terraform.tfvars after
# scaffolding, `terraform apply` renames the underlying GCP resources, but
# GitHub has no way to know that happened — GCP_SERVICE_NAME/
# GCP_MIGRATE_JOB_NAME/GCP_ARTIFACT_REGISTRY_URL stay pointed at the OLD
# names until you manually re-run the `gh variable set` commands below
# (see docs/guide/deployment.md's GCP section) with fresh `terraform
# output` values. Forgetting this step means the next tag push builds and
# deploys against resources that may no longer exist.
name: gcp-deploy
on:
push:
tags:
- "v*"
workflow_dispatch:
env:
# The posture manifest's path from the repository root. A workspace member
# sets `<member dir>/security-posture.json`: `hashFiles` and `subject-path`
# ignore `working-directory`.
POSTURE_MANIFEST: security-posture.json
jobs:
build-and-deploy:
runs-on: ubuntu-latest
# Two overlapping runs (e.g. two rapid tag pushes, or a tag push racing
# a manual dispatch) must not interleave: the older run's later
# `gcloud run services update` could execute after the newer one and
# roll production back, or leave stale code serving against a schema
# the newer run just migrated. One queue per repo, `cancel-in-progress:
# false` so a run is never killed mid-migration/mid-cutover — it waits
# its turn instead.
concurrency:
group: gcp-deploy-${{ github.repository }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
actions: read
# Publish a build-provenance attestation for the pushed image and an
# SBOM attestation for the inventory baked into it (issue #1615). These
# are keyless Sigstore signatures: `id-token: write` above supplies the
# OIDC identity, so there is no signing key to store or rotate. See
# docs/guide/supply-chain.md for the `gh attestation verify` command
# your users run against them.
attestations: write
steps:
- uses: actions/checkout@v4
# Must be unique per run, not just per ref: two workflow_dispatch runs
# on the same branch would otherwise compute the identical tag despite
# different commits. Re-pushing bytes under a tag the Cloud Run
# service already has running isn't guaranteed to register as a
# deployable change on its own — this workflow always calls `gcloud
# run services update --image` explicitly regardless, but a stable,
# collision-free tag still matters for traceability in Artifact
# Registry. The commit SHA is always unique; keep the sanitized
# ref/tag as a human-readable prefix (a `v*` push tag or a
# workflow_dispatch branch name may legally contain characters Docker
# tags reject — not just "/" in branches like "feature/login", but
# e.g. "+" in a SemVer tag like "v1.2.3+build" — so it still needs
# sanitizing).
- name: Compute a unique, Docker-safe image tag
id: image_tag
run: |
SAFE_REF=$(echo -n "$GITHUB_REF_NAME" | tr -c 'A-Za-z0-9_.-' '-' | cut -c1-80)
# Docker's tag grammar requires the FIRST character to be a word
# character ([A-Za-z0-9_]) — "." and "-" are allowed elsewhere but
# not at position zero. A ref starting with e.g. "+hotfix" would
# otherwise sanitize to a leading "-", which `docker build` rejects.
SAFE_REF=$(echo -n "$SAFE_REF" | sed -E 's/^[.-]+//')
[ -z "$SAFE_REF" ] && SAFE_REF="build"
# GITHUB_SHA alone isn't enough: a workflow_dispatch re-run (or
# clicking "Re-run jobs") on the same ref reuses the identical
# commit, so a tag built only from ref+SHA collides with the
# previous run's tag despite this being a genuinely different
# build — a fresh AUTUMN_BUILD_TIMESTAMP, and possibly different
# bytes entirely if base image packages updated in between.
# GITHUB_RUN_ID is unique per trigger; GITHUB_RUN_ATTEMPT
# disambiguates re-runs of that same trigger — together they make
# the tag unique per actual execution, not just per commit.
echo "value=${SAFE_REF}-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
# `project_id` matters even though the deployer authenticates via WIF:
# without it, gcloud's active project defaults to whatever project the
# deployer service account itself lives in (e.g. a shared CI project),
# not the project this scaffold's resources were created in — every
# `gcloud run`/`gcloud auth configure-docker` call below would then
# target the wrong project, or fail to find the service/job at all.
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_DEPLOYER_SERVICE_ACCOUNT }}
project_id: ${{ vars.GCP_PROJECT_ID }}
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker "$(echo "$AR_URL" | cut -d/ -f1)" --quiet
env:
AR_URL: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}
# The Dockerfile declares AUTUMN_BUILD_* ARGs (see its own header
# comment) that build.rs bakes into the binary for /actuator/info's
# git provenance — but ARGs default to empty unless passed at
# `docker build` time, and .dockerignore excludes .git from the build
# context, so build.rs has no other way to discover them. Without
# these, every image this workflow builds reports null git info.
- name: Build release image
run: |
docker build \
--build-arg AUTUMN_BUILD_GIT_SHA="$GITHUB_SHA" \
--build-arg AUTUMN_BUILD_GIT_SHA_SHORT="${GITHUB_SHA:0:8}" \
--build-arg AUTUMN_BUILD_GIT_BRANCH="$GITHUB_REF_NAME" \
--build-arg AUTUMN_BUILD_GIT_DIRTY=false \
--build-arg AUTUMN_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t "$AR_URL/$GCP_SERVICE_NAME:$IMAGE_TAG" .
env:
AR_URL: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}
GCP_SERVICE_NAME: ${{ vars.GCP_SERVICE_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Push to Artifact Registry
run: docker push "$AR_URL/$GCP_SERVICE_NAME:$IMAGE_TAG"
env:
AR_URL: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}
GCP_SERVICE_NAME: ${{ vars.GCP_SERVICE_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
# The concurrency group above prevents two runs from executing at the
# same time, but GitHub does not document strict FIFO ordering for
# which queued run goes next. A same-ref check isn't enough either:
# two DIFFERENT immutable tags (e.g. v1 then v2) each trigger their
# own run against their own never-moving $GITHUB_REF, so checking
# only "has my ref moved" is blind to a newer release landing under a
# different tag. `run_number` is GitHub's own counter, monotonically
# increasing in trigger order for this workflow file regardless of
# which ref triggered it — so check right before the point of no
# return (migrating/deploying) whether any OTHER run of this workflow
# with a higher run_number exists AT ALL, regardless of its status or
# conclusion. Filtering to only "still active" or "completed
# successfully" runs is NOT enough: a newer run can migrate (the
# actual point of no return — the schema is advanced) and then fail
# on the deploy step after it, reporting an overall conclusion of
# "failure" — a status-filtered check would miss it entirely and let
# this older run migrate/deploy right over a release whose schema
# change already went out. There's no cheap way to distinguish
# "failed before migrating" from "failed after migrating" from a
# run's top-level status, so treat the mere existence of any newer
# run as disqualifying, full stop — if it failed for an unrelated CI
# reason before ever reaching migration, the fix is to re-trigger it,
# not to let an older run sneak through in the meantime.
- name: Abort if a newer run of this workflow exists
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
NEWER=$(gh api "repos/${{ github.repository }}/actions/workflows/gcp-deploy.yml/runs?per_page=50" \
--jq "[.workflow_runs[] | select(.run_number > ${{ github.run_number }})] | length")
if [ "$NEWER" -gt 0 ]; then
echo "::error::a newer run of this workflow (run_number > ${{ github.run_number }}) exists — this run is superseded, aborting before migrating/deploying out of order"
exit 1
fi
# Runs `autumn migrate` to completion in a one-shot Cloud Run Job
# BEFORE the service is updated, so new revisions never start against
# an unmigrated schema (production config disables startup
# migrations — see docs/guide/deployment.md). `gcloud run jobs update
# --image` persists the new image onto the job's stored template
# (leaving command/env untouched, unlike a full replace), and `gcloud
# run jobs execute --wait` blocks until the execution actually
# finishes — no manual poll loop needed, unlike the Azure/AWS targets,
# which lack an equivalent synchronous CLI primitive. `--wait` exits
# non-zero when the execution fails, which — combined with
# `set -euo pipefail` — aborts the workflow before the deploy step
# below ever runs.
- name: Run database migrations
run: |
set -euo pipefail
gcloud run jobs update "$GCP_MIGRATE_JOB_NAME" \
--region "$GCP_REGION" \
--image "$AR_URL/$GCP_SERVICE_NAME:$IMAGE_TAG" \
--quiet
gcloud run jobs execute "$GCP_MIGRATE_JOB_NAME" \
--region "$GCP_REGION" \
--wait
env:
AR_URL: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}
GCP_SERVICE_NAME: ${{ vars.GCP_SERVICE_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
GCP_REGION: ${{ vars.GCP_REGION }}
GCP_MIGRATE_JOB_NAME: ${{ vars.GCP_MIGRATE_JOB_NAME }}
- name: Deploy new image to Cloud Run
run: |
gcloud run services update "$GCP_SERVICE_NAME" \
--region "$GCP_REGION" \
--image "$AR_URL/$GCP_SERVICE_NAME:$IMAGE_TAG" \
--quiet
env:
AR_URL: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}
GCP_SERVICE_NAME: ${{ vars.GCP_SERVICE_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
GCP_REGION: ${{ vars.GCP_REGION }}
# ── Supply chain (issue #1615) ──────────────────────────────────────
# Deliberately the LAST steps in the job. The image is already in the
# registry by the time they run, so attesting first would buy nothing
# while letting a Sigstore hiccup — or a repository whose plan does not
# include artifact attestations — block a production deploy that is
# otherwise ready. `continue-on-error` keeps that a visible warning
# rather than a failed deploy of code that shipped fine.
#
# Both attestations bind the image DIGEST, never its tag: a tag can be
# re-pointed at different bytes later, a digest cannot.
- name: Resolve the pushed image digest
id: image_digest
continue-on-error: true
run: |
set -euo pipefail
# Match on the repository we actually pushed to. `RepoDigests` is a
# property of the image ID and can hold entries for several
# repositories; taking element 0 by position would attest whichever
# one the daemon happened to record first. `|| true` keeps the
# explicit diagnostic below reachable instead of dying inside the
# command substitution.
DIGEST=$(docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' \
"$AR_URL/$GCP_SERVICE_NAME:$IMAGE_TAG" 2>/dev/null | grep "^$AR_URL/$GCP_SERVICE_NAME@" | head -n1 | cut -d@ -f2 || true)
if [ -z "$DIGEST" ]; then
echo "::warning::could not resolve the digest of the image just pushed; skipping attestation"
exit 1
fi
echo "value=$DIGEST" >> "$GITHUB_OUTPUT"
env:
AR_URL: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}
GCP_SERVICE_NAME: ${{ vars.GCP_SERVICE_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Attest build provenance for the image
if: steps.image_digest.outcome == 'success'
continue-on-error: true
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}/${{ vars.GCP_SERVICE_NAME }}
subject-digest: ${{ steps.image_digest.outputs.value }}
{{sbom_steps_begin}}
# The image ships a CycloneDX SBOM at a fixed path (see the Dockerfile's
# `autumn sbom` step). Attest it against the same digest so "what is in
# this image" is verifiable without taking the image's word for it.
- name: Extract the SBOM baked into the image
id: image_sbom
if: steps.image_digest.outcome == 'success'
continue-on-error: true
run: |
set -euo pipefail
CID=$(docker create "$AR_URL/$GCP_SERVICE_NAME:$IMAGE_TAG")
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
docker cp "$CID:/usr/share/autumn/sbom.cdx.json" sbom.cdx.json
env:
AR_URL: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}
GCP_SERVICE_NAME: ${{ vars.GCP_SERVICE_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Attest the image SBOM
if: steps.image_sbom.outcome == 'success'
continue-on-error: true
uses: actions/attest-sbom@v3
with:
subject-name: ${{ vars.GCP_ARTIFACT_REGISTRY_URL }}/${{ vars.GCP_SERVICE_NAME }}
subject-digest: ${{ steps.image_digest.outputs.value }}
sbom-path: sbom.cdx.json
{{sbom_steps_end}}
# ── Security posture manifest (issue #1624) ─────────────────────────
# Sign the posture manifest this release ships — the same bytes the
# posture gate diffed and a reviewer acknowledged on the pull request.
# Attested as a file rather than baked into the image: the manifest is a
# property of the *source revision*, and attesting the committed copy is
# what lets a deploy prove the running app's declared security surface is
# the one that was reviewed.
#
# At deploy time, one command proves both halves:
#
# autumn routes posture verify --manifest security-posture.json \
# --expect-digest <digest recorded on the PR> --repo <owner>/<repo>
#
# See docs/guide/posture-gate.md. `continue-on-error` for the same reason
# as the attestations above: a Sigstore hiccup is a visible warning, not a
# blocked deploy of code that shipped fine.
- name: Attest the security posture manifest
if: hashFiles(env.POSTURE_MANIFEST) != ''
continue-on-error: true
uses: actions/attest-build-provenance@v3
with:
subject-path: ${{ env.POSTURE_MANIFEST }}
# Not a failure: an app that never adopted the posture gate must deploy.
# An app that did adopt it, with the wrong path, needs this message.
- name: Warn when the security posture manifest is missing
if: hashFiles(env.POSTURE_MANIFEST) == ''
run: echo "::warning::${POSTURE_MANIFEST} not found, so this release has no posture attestation. Ignore this if the app has not adopted the posture gate. If it has, set POSTURE_MANIFEST to the manifest path from the repository root."