# .github/workflows/azure-deploy.yml — generated by
# `autumn release init --target azure-container-apps`
#
# 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):
# AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_SUBSCRIPTION_ID
# — an app registration with a federated credential for this repo (OIDC;
# see `az ad app federated-credential create`), granted AcrPush on the
# registry and Contributor at the RESOURCE-GROUP scope — not just on
# the Container App: this workflow also starts the sibling migration
# Container Apps Job, and Azure RBAC granted on one resource does not
# inherit to another, even a sibling in the same environment. The
# same scope lets azure-cutover.sh assign the app's user-assigned
# identity (#2314).
# No client secret is needed: `permissions.id-token: write` below plus
# azure/login's client-id/tenant-id/subscription-id inputs is enough
# for OIDC login.
#
# Required repository variables (same Settings page, "Variables" tab — not
# secret, just config the job needs):
# ACR_LOGIN_SERVER — output of `terraform output acr_login_server`.
# AZURE_RESOURCE_GROUP — output of `terraform output resource_group_name`.
# AZURE_MIGRATE_JOB_NAME — output of `terraform output migrate_job_name`.
# AZURE_APP_NAME — output of `terraform output app_name`. Always
# read from Terraform, never hardcoded: if `app_name` is edited in
# terraform.tfvars after scaffolding, Terraform renames the Container
# App to match, and this workflow must follow — not target whatever the
# project was called when `autumn release init` ran.
name: azure-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
# cutover (azure-cutover.sh) could run 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: azure-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 Azure already has
# configured on the Container App isn't guaranteed to register as a
# revision-scope change (Azure Container Apps' revision model — see
# https://learn.microsoft.com/en-us/azure/container-apps/revisions —
# keys off the image reference string, not its digest), so the old
# binary could keep serving against a newly migrated schema. 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.
# Pushing different bytes under a tag Azure already has
# configured on the app isn't guaranteed to register as a
# revision-scope change. 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"
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Log in to Azure Container Registry
run: az acr login --name "${ACR_LOGIN_SERVER%%.azurecr.io}"
env:
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
# 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 "$ACR_LOGIN_SERVER/$AZURE_APP_NAME:$IMAGE_TAG" .
env:
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
AZURE_APP_NAME: ${{ vars.AZURE_APP_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
- name: Push to Azure Container Registry
run: docker push "$ACR_LOGIN_SERVER/$AZURE_APP_NAME:$IMAGE_TAG"
env:
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
AZURE_APP_NAME: ${{ vars.AZURE_APP_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/azure-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 Container Apps Job
# BEFORE the app is updated, so new replicas never start against an
# unmigrated schema (production config disables startup migrations —
# see docs/guide/deployment.md). Aborts the workflow — never reaching
# the deploy step below — if the migration job fails.
- name: Run database migrations
run: |
set -euo pipefail
# `job start --image` sends an execution-TEMPLATE OVERRIDE, which
# Azure treats as a full replacement, not a merge — an override
# containing only --image drops the command (`autumn migrate`) and
# the AUTUMN_DATABASE__PRIMARY_URL secret env Terraform configured
# on the job, so the execution would run the container's default
# command with no DB URL instead of applying migrations. `job
# update --image` persists just the image onto the job's STORED
# template, leaving command/env untouched; the bare `job start`
# that follows then runs that complete, up-to-date template.
az containerapp job update \
--name "$AZURE_MIGRATE_JOB_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--image "$ACR_LOGIN_SERVER/$AZURE_APP_NAME:$IMAGE_TAG" \
--output none
EXECUTION=$(az containerapp job start \
--name "$AZURE_MIGRATE_JOB_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query name -o tsv)
# Poll for completion — `az containerapp job execution list` is the
# documented way to check a started execution's status; there is no
# blocking "wait" subcommand. Budget MUST exceed the migration
# job's own replica_timeout_in_seconds (600s, main.tf) — Azure
# self-terminates the execution at that point regardless, so
# polling any shorter risks reporting "timed out" on a migration
# that's still validly running and would have succeeded, while
# leaving it to keep mutating the schema in the background after
# this workflow has already given up and left the old app live.
# 660s (66 x 10s) gives 60s of margin over that ceiling.
for _ in $(seq 1 66); do
STATUS=$(az containerapp job execution list \
--name "$AZURE_MIGRATE_JOB_NAME" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query "[?name=='$EXECUTION'].properties.status" -o tsv)
case "$STATUS" in
Succeeded) exit 0 ;;
Failed|Stopped)
echo "::error::migration job execution $EXECUTION $STATUS" >&2
exit 1
;;
esac
sleep 10
done
echo "::error::migration job execution $EXECUTION did not finish within the time budget" >&2
exit 1
env:
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
AZURE_APP_NAME: ${{ vars.AZURE_APP_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
AZURE_RESOURCE_GROUP: ${{ vars.AZURE_RESOURCE_GROUP }}
AZURE_MIGRATE_JOB_NAME: ${{ vars.AZURE_MIGRATE_JOB_NAME }}
# Terraform creates the app with no identity, registry or secret refs
# (#2314). azure-cutover.sh copies them from the migration job and sets
# the real image in one write. Then it opens external ingress (#2312).
- name: Deploy new image to Azure Container Apps
run: bash azure-cutover.sh
env:
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
AZURE_APP_NAME: ${{ vars.AZURE_APP_NAME }}
IMAGE_TAG: ${{ steps.image_tag.outputs.value }}
AZURE_RESOURCE_GROUP: ${{ vars.AZURE_RESOURCE_GROUP }}
AZURE_MIGRATE_JOB_NAME: ${{ vars.AZURE_MIGRATE_JOB_NAME }}
# Optional: set it only if you changed bootstrap_image in Terraform.
AZURE_BOOTSTRAP_IMAGE: ${{ vars.AZURE_BOOTSTRAP_IMAGE }}
# ── 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}}' \
"$ACR_LOGIN_SERVER/$AZURE_APP_NAME:$IMAGE_TAG" 2>/dev/null | grep "^$ACR_LOGIN_SERVER/$AZURE_APP_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:
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
AZURE_APP_NAME: ${{ vars.AZURE_APP_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.ACR_LOGIN_SERVER }}/${{ vars.AZURE_APP_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 "$ACR_LOGIN_SERVER/$AZURE_APP_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:
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
AZURE_APP_NAME: ${{ vars.AZURE_APP_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.ACR_LOGIN_SERVER }}/${{ vars.AZURE_APP_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."