autumn-cli 0.7.0

CLI tool for the Autumn web framework
# .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 }}