autumn-cli 0.7.0

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

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
    # `az containerapp 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: azure-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 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 }}

      - name: Deploy new image to Azure Container Apps
        run: |
          az containerapp update \
            --name "$AZURE_APP_NAME" \
            --resource-group "$AZURE_RESOURCE_GROUP" \
            --image "$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 }}
          AZURE_RESOURCE_GROUP: ${{ vars.AZURE_RESOURCE_GROUP }}