# .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:
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
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 }}