# ADR: The deploy pipeline

Status: accepted, 2026-09-02, the morning it shipped. The first automated
deploy rolled 1.0.0.12 onto the live site with no hands on the runner.

## In plain words

A change reaches the live site with no person involved. When the tests pass on the main branch, a second automated job (GitHub Actions) builds and ships the new image, then checks that the live site reports the new version. It signs in to Azure with a short-lived token (OIDC), so no password or key is stored anywhere.

What that is worth: a developer merges and the deploy follows on its own with nothing to rotate or leak, and the organization gets releases that need neither a laptop nor a stored credential.

## Context

Until this morning every ship was a scripted manual pipeline: run the three
suites, build the image on the laptop, push it to the registry, export the
running container group, strip the lines Azure refuses, patch the tag,
recreate the group, poll, verify. Logged end to end and gated on green
tests, but a person still started it and a laptop still had to be on. The
version number (1.0.0.N) was typed by hand for each ship.

The goal, in one sentence: a merge to main reaches the live site with no
human step and no credential stored anywhere.

## Decision

A second GitHub Actions workflow, Deploy, separate from CI on purpose.

- **It fires when CI finishes green on main**, through GitHub's
  workflow_run trigger, and checks out the exact commit CI tested rather
  than whatever main has moved to since. A red CI run still fires the
  event; a job-level condition drops it before anything builds.
- **It builds the image with the two footer build arguments**, APP_VERSION
  and APP_COMMIT, so the page keeps reporting exactly what is running
  (ADR: Version in the footer, under Best Practices).
- **It pushes to the registry and rolls the container group from a
  template checked into infra/**, aci-theyard.yaml, the same file every
  deploy uses. The template is the v11 export with the three line
  families Azure rejects removed and the image line replaced by a
  placeholder the workflow fills in. It holds no secrets: the registry
  pull rides the container group's user-assigned identity.
- **Authentication is OIDC federated credentials.** The workflow asks
  GitHub for a short-lived token for this repository and this branch, and
  Azure trusts that token for one app registration whose federated
  credential subject is pinned to
  repo:SteveStout@317307255/TheYard@1352398185:ref:refs/heads/main.
  GitHub holds three identifiers as repository variables (client, tenant,
  subscription), which are not secrets. No password, key, or secret exists
  anywhere in the pipeline, so there is nothing to rotate and nothing to
  leak.
- **Roles are scoped to least privilege**: AcrPush on the one registry,
  Azure Container Instances Contributor Role on the one resource group, and
  Managed Identity Operator on the one identity the container group assigns
  at create time. The scoped set worked on the first deploy; the wider
  Contributor fallback the plan allowed for was never needed.
- **Displayed versions are read from the changelog's top line.** They were
  1.0.0.(11 + deploy run number) until 1.0.0.41, the offset being what the
  footer showed the morning this workflow was written. A red CI run consumes a
  run number without shipping anything, so that formula could name a version
  nothing ever displayed, and once did. ADR: The version comes from the
  changelog holds the replacement and the incident.
- **Deploys are serialized.** A concurrency group makes two quick merges
  wait their turn instead of fighting over one container group.
- **The workflow verifies what it shipped** the way the runner scripts did:
  it waits for the origin to answer with the new version string, then
  checks the domain for the same version and commit and a live inventory
  endpoint. A green Deploy run means what a green runner log meant.

## In the code

The workflow is [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml)
and the template it renders is [`infra/aci-theyard.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard.yaml).
The samples below are read from this build's copy of the workflow each time
the page is served (ADR: Live code samples). The trigger and the gate:

```yaml
# Deploy fires on the push itself. Until 1.0.0.173 it waited for CI to run
# every suite again on GitHub's runners, four and a half minutes spent proving
# what the gate had proven a minute earlier on both stores; Steve's rule is
# that every check runs once, and the one run is the gate, which is the only
# thing that pushes to main (ADR: The five-minute gate, addendum of 21
# September). CI still runs every suite on a pull request.
on:
  push:
    branches: [main]
    # A push that touches only the sample under samples/ rolls the sample and not
    # the site; deploy-shed.yml watches that folder (ADR-009, the addendum of 29
    # September on the sample). A push that touches both rolls both.
    paths-ignore:
      - "samples/**"
      - ".github/workflows/deploy-shed.yml"

permissions:
  id-token: write
  contents: read

# #region latest-push-wins
# The latest push wins (ADR-009, addendum of 29 September). A newer push to
# main cancels the run in flight, queued or rolling, and rolls the newest
# commit, so lanes stack their pushes and the site is never rolled twice in a
# row to reach the same place. Verify waits for the version it built, so a
# roll cut off between its two calls is finished by the next run.
concurrency:
  group: deploy-production
  cancel-in-progress: true
# #endregion latest-push-wins

jobs:
  deploy:
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L13-L45), region `deploy-trigger`, read from this build at 1d6b50b.*

The checkout pinned to the commit CI tested, the version arithmetic, and
the changelog check that joined the step later (ADR: The changelog):

```yaml
# Pin to the exact commit the gate tested and pushed, not wherever main has moved since.
- uses: actions/checkout@v7
  with:
    ref: ${{ github.sha }}

- name: Compute version
  env:
    HEAD_SHA: ${{ github.sha }}
  run: |
    # The changelog's top line is the version (ADR-031). Not a run
    # counter: a counter ticks on runs that are gated off and never ship,
    # which is how 1.0.0.39 became a number the changelog named and
    # nothing ever displayed.
    # Four numbers since 1.0.1.0, compared as a version (sort -V), not as
    # the one counter it was while every version began 1.0.0.
    VERSIONS=$(sed -n 's/^- \*\*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)\*\*.*/\1/p' docs/CHANGELOG.md)
    N=$(echo "$VERSIONS" | sed -n 1p)
    PREV=$(echo "$VERSIONS" | sed -n 2p)
    # #region changelog-check
    # Both of these used to be a warning nobody reads. They stop the
    # deploy now, because the file they check is where the version comes
    # from: shipping past either one means shipping a number with no
    # sentence attached to it (ADR-031).
    if [ -z "$N" ]; then
      echo "::error file=docs/CHANGELOG.md::No version line at the top of the changelog, so there is no version to ship"
      exit 1
    fi
    if [ -n "$PREV" ] && { [ "$N" = "$PREV" ] || [ "$(printf '%s\n%s\n' "$PREV" "$N" | sort -V | tail -1)" != "$N" ]; }; then
      echo "::error file=docs/CHANGELOG.md::The top line is $N and $PREV is already below it, so this ship has no changelog line of its own"
      exit 1
    fi
    # #endregion changelog-check
    {
      echo "N=$N"
      echo "APP_VERSION=$N"
      echo "APP_COMMIT=${HEAD_SHA:0:7}"
      echo "IMAGE=$ACR/theyard:v$N"
    } >> "$GITHUB_ENV"
    echo "Deploying $N from ${HEAD_SHA:0:7} as $ACR/theyard:v$N"
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L75-L113), region `compute-version`, read from this build at 1d6b50b.*

The roll, which is the whole deploy step:

```yaml
# A web app's settings live on the web app, so a roll is two calls: the
# four values the template does not hold, then the image
# (ADR: One plan, two sites). Both answer with -o none, because the first
# would otherwise print every setting the site holds into this log. The
# plan, the sites and every other setting are infra/appservice.bicep, and
# a roll changes none of them.
- name: Roll the site
  env:
    YARD_AUTH_SIGNING_KEY: ${{ secrets.YARD_AUTH_SIGNING_KEY }}
    ADMIN_KEY: ${{ secrets.ADMIN_KEY }}
  run: |
    # The Application Insights connection string is an ingestion key, so it
    # lives only in Azure: read it here with the credential this job already
    # holds and mask it in the log. az resource show is core CLI; az monitor
    # app-insights would pull an extension at run time, which is a second
    # thing that can fail a deploy. Telemetry is an addition, not a
    # dependency: if the read fails, the site keeps the value it has and
    # still ships, because a deploy pipeline held hostage to its own
    # observability is worse than a quiet hour of no traces.
    CONN=$(az resource show -g "$RG" -n "$AI_NAME" \
      --resource-type "Microsoft.Insights/components" \
      --query "properties.ConnectionString" -o tsv 2>/dev/null || true)
    if [ -n "$CONN" ]; then
      echo "::add-mask::$CONN"
    fi
    # The database. The value names a server, a database and an
    # authentication mode; the credential is the site's managed identity,
    # which never leaves Azure. The name is composed rather than read back
    # from Azure: the first version of this asked `az sql server show`,
    # which this job's identity is not allowed to do, the call failed
    # silently, and 1.0.0.49 shipped on SQLite and said so. An Azure SQL
    # logical server's address is its name plus the service suffix. No
    # Connect Timeout, on purpose: YardConnection widens a timeout nobody
    # gave to sixty seconds, and a value given here wins over that
    # (ADR: The SQL Server backend).
    YARD_SQL=""
    if [ -n "$SQL_SERVER" ]; then
      YARD_SQL="Server=tcp:${SQL_SERVER}.database.windows.net,1433;Initial Catalog=${SQL_DB};Encrypt=True;TrustServerCertificate=False;Authentication=Active Directory Managed Identity;User Id=${MI_CLIENT_ID};"
    fi
    # #region secrets
    # The session signing key and the operator's key, from the repository
    # secrets of the same names, handed to the step through env and written
    # to a file by python, so neither is ever part of a command line and
    # neither appears in a log: GitHub masks them, and the settings call
    # prints nothing. A value this job does not hold is left out, and the
    # site keeps what it has: the template's placeholder on a new site,
    # which the app reads as "no key", or the last roll's value
    # (ADR: Three readers with no memory of the project; ADR: Site
    # activity, and the line an address does not cross).
    CONN="$CONN" YARD_SQL="$YARD_SQL" python3 - "$RUNNER_TEMP/settings.json" <<'PY'
    import json, os, sys
    wanted = {
        "APPLICATIONINSIGHTS_CONNECTION_STRING": os.environ.get("CONN", ""),
        "ConnectionStrings__YardSql": os.environ.get("YARD_SQL", ""),
        "Auth__SigningKey": os.environ.get("YARD_AUTH_SIGNING_KEY", ""),
        "Admin__Key": os.environ.get("ADMIN_KEY", ""),
    }
    held = [{"name": name, "value": value, "slotSetting": False} for name, value in wanted.items() if value]
    for name, value in wanted.items():
        if not value:
            print(f"::warning::{name} is not held by this job; the site keeps the value it has")
    json.dump(held, open(sys.argv[1], "w", encoding="utf-8"))
    print(f"{len(held)} of {len(wanted)} values go to the site (values masked)")
    PY
    # #endregion secrets
    if [ "$(cat "$RUNNER_TEMP/settings.json")" != "[]" ]; then
      az webapp config appsettings set -g "$RG" -n "$APP" --settings "@$RUNNER_TEMP/settings.json" -o none
    fi
    rm -f "$RUNNER_TEMP/settings.json"
    # The image is the only thing that changes between rolls. App Service
    # starts the new container beside the old one and moves traffic when
    # the new one answers, so the site serves through its own roll.
    az webapp config set -g "$RG" -n "$APP" --linux-fx-version "DOCKER|$IMAGE" -o none
    az webapp show -g "$RG" -n "$APP" --query "{state:state, image:siteConfig.linuxFxVersion, host:defaultHostName}" -o json
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L148-L221), region `roll`, read from this build at 1d6b50b.*

The footer build arguments the image is stamped with live in the
[`Dockerfile`](https://github.com/SteveStout/TheYard/blob/main/Dockerfile) (ADR: Version in the footer).

## The first run failed, and the failure is worth keeping

Deploy run 1 stopped at the Azure sign-in with AADSTS700213: no federated
identity record matched the presented subject. The credential had been
created with the form every guide shows, repo:SteveStout/TheYard:ref:refs/heads/main.
The token GitHub actually presents carries the owner id and the repository
id inside the subject, repo:SteveStout@317307255/TheYard@1352398185:ref:refs/heads/main,
so a renamed or re-created repository can never inherit the trust. Azure
matches the subject character for character. The credential was updated to
the measured form and the run re-ran green. The lesson: read the subject
off the failing assertion, never off a guide.

## What this replaced

The scripted manual pipeline retires to fallback duty and stays documented,
because it is also the rollback: az container create against the kept v11
export restores the last manual build in one command, and the same command
against any later export restores that build. Nothing was deleted.

## Consequences

- The laptop no longer has to be on for a ship, and no version number is
  typed by hand again.
- The roll still has the same short restart window the manual pipeline
  had, roughly a minute while the group recreates. Accepted for this demo;
  deployment slots on App Service are the production answer, already
  covered by the undeployed Bicep design under Hosting.
- GitHub's hosted runner builds the image now, so a ship costs nothing on
  the laptop and the Docker Desktop dependency is gone from the path.
- The manual scripts, the export-and-strip step included, are now
  documentation of how it used to work rather than the way it works.

## Addendum, 2026-09-20: a roll is two calls on a web app

Superseded in part on 2026-09-20, as 1.0.0.156. The roll above rendered `infra/aci-theyard.yaml`
and ran `az container create`. The sites are web apps on an App Service plan now
(ADR: One plan, two sites), and a web app keeps its settings on itself, so the roll is two calls:
the four values the template does not hold (the telemetry connection string, the database
connection string, the session signing key and the operator's key), written to a file by python and
sent with `-o none` so nothing is printed, and then the image. The live sample above is that step
as it is today. App Service starts the new container beside the old one and moves traffic when the
new one answers, so a site serves through its own roll, which a container group never did. The
deploy identity holds Website Contributor on the two sites and nothing else new.

## Where it sits

The deploy pipeline is a deployment decision made in GitHub Actions workflows, outside every ring, so the onion and SOLID do not apply to it.

## Files

- [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml): the whole pipeline; its trigger,
  version, changelog check and roll are the live blocks above.
- [`.github/workflows/ci.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/ci.yml): the gate it waits for.
- [`infra/aci-theyard.yaml`](https://github.com/SteveStout/TheYard/blob/main/infra/aci-theyard.yaml): the template it renders and rolls.
- [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/main/Dockerfile): the image it builds, with the two provenance
  arguments (ADR: Version in the footer).
- [`docs/ci-cd/CICD.md`](https://github.com/SteveStout/TheYard/blob/main/docs/ci-cd/CICD.md): the design written before the build, and the
  screenshots of the runs.

## Addendum, 2026-09-09: one secret, where there were none

"No password, key, or secret exists anywhere in the pipeline" was true until
1.0.0.111. There is one now: the session signing key, a repository secret
named `YARD_AUTH_SIGNING_KEY` that the roll step reads through its
environment and substitutes into the container spec the way it substitutes
the connection strings, for both container groups, so a roll no longer ends
every session and a token minted by one container reads on the other. It is
the only thing the pipeline holds that is worth keeping from a reader, GitHub
masks it in every log, and the roll writes it into the group's environment as
a secure value and nowhere else. A roll with no secret keeps the placeholder,
which the application reads as no key at all, and behaves as every roll did
before (ADR: Three readers with no memory of the project). Rotating it is
changing the secret and rolling: every session ends once, which is what
every roll did until now.

## More of the code

The Verify step: the origin must serve the new version, then answer
readiness, then the domain must agree ([`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml)):

```yaml
# A deploy is not done when Azure returns; it is done when the new build
# answers. This waits for the version it just built, at the origin and then
# at the domain, and fails the run if it never appears, so a bad roll
# cannot be reported as a good one.
- name: Verify
  run: |
    # Wait for the NEW build to answer at the origin. The plan is one shared
    # processor, and the image has to be pulled and started on it before the
    # new build answers. Every read
    # here carries the self mark on its agent, so the Site activity card
    # counts it as the site's own (ADR: Site activity, 24 September).
    # Twenty-five minutes, read off the rolls: the new container starts 8 to 12
    # minutes after the image push and is ready 2 to 3.5 minutes after that, and
    # a ten-minute loop ended a roll that worked (ADR: The deploy pipeline, the
    # addendum on sizing Verify). Thirty seconds a read, because an API read
    # waits for the catalogue to load.
    budget=1500
    started=$SECONDS
    body=""
    until [[ "$body" == *"\"$APP_VERSION\""* ]]; do
      if (( SECONDS - started >= budget )); then
        echo "origin never served $APP_VERSION in ${budget}s (last answer: $body)"
        exit 1
      fi
      sleep 5
      body=$(curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$ORIGIN/api/version" || true)
    done
    echo "origin serves $APP_VERSION after $((SECONDS - started))s: $body"
    # Readiness is the container's own word that its files are in place and its
    # catalogue has loaded (ADR-010). The process listens before the catalogue is
    # in (ADR: The ports learn to wait, the addendum on listening first). An API
    # read waits for the load, so the version above usually arrives with it;
    # this wait says so in the container's own words before the filters are read.
    for i in $(seq 1 120); do
      if curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 10 "$ORIGIN/readyz" > /dev/null 2>&1; then
        echo "origin ready $(((i - 1) * 5))s after it served the version"
        break
      fi
      if [[ "$i" -eq 120 ]]; then
        echo "origin never said ready"
        exit 1
      fi
      sleep 5
    done
    curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 20 "$ORIGIN/api/facets" > /dev/null
    echo
    # The domain is the edge in front of the same origin: what a visitor sees.
    site=$(curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$SITE/api/version")
    echo "domain: $site"
    if [[ "$site" != *"\"$APP_VERSION\""* || "$site" != *"\"$APP_COMMIT\""* ]]; then
      echo "domain does not report $APP_VERSION at $APP_COMMIT"
      exit 1
    fi
    curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$SITE/api/facets" > /dev/null
    # Which store the container actually came up on, said out loud.
    #
    # A warning and not a failure, on purpose. The whole point of the
    # file-backed fallback is that the site serves when the database does
    # not (ADR: The relational store), so a roll that lands on SQLite is
    # the design working and must not fail a deploy. What it must not be
    # is silent: 1.0.0.49 shipped that way and nothing said so until
    # somebody read the health endpoint by hand.
    store=$(curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$SITE/api/health" || true)
    case "$store" in
      *"Azure SQL Database"*) echo "the site is on Azure SQL Database" ;;
      *SQLite*) echo "::warning::this roll is serving from SQLite, not Azure SQL Database" ;;
      *) echo "::warning::could not tell which store the container is on" ;;
    esac
    # Azure's own view of the container (the Admin tab's card), informational.
    curl -sS --max-time 40 "$SITE/api/admin/azure" || true
    echo
    {
      echo "## Deployed $N"
      echo "- commit: $APP_COMMIT"
      echo "- image: $IMAGE"
      echo "- site: $SITE"
    } >> "$GITHUB_STEP_SUMMARY"
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L224-L300), region `verify`, read from this build at 1d6b50b.*

## Addendum, 2026-09-21: the deploy starts on the push, and the two sites roll together

Deploy fired when CI finished green on main, and Deploy Cosmos fired when
Deploy finished, so a version waited four and a half minutes for CI to prove
again what the gate had proven on both stores a minute earlier, and the
second site waited for the whole of the first site's roll. From 1.0.0.173
both workflows start on the push to main, which only a green gate makes (ADR:
The five-minute gate, the addendum on every check running once). Deploy
builds and pushes the image and rolls the first site; Deploy Cosmos waits for
that image's tag to be in the registry, ten minutes at most, and rolls the
second site beside it. The plan's one machine starts both new containers at
once, which costs each a little and saves the whole of one roll.

```yaml
# Deploy builds the image this site rolls to, on the same push. Wait for
# the tag to be in the registry, ten minutes at most, rather than for
# Deploy to finish: the build is the only thing this leg needs from it.
- name: Wait for the image
  if: github.event_name == 'push'
  run: |
    for i in $(seq 1 120); do
      if az acr repository show-tags -n "$REGISTRY_NAME" --repository theyard -o tsv 2>/dev/null | grep -qx "v$APP_VERSION"; then
        echo "$IMAGE is in the registry after $((i * 5 - 5))s"
        exit 0
      fi
      sleep 5
    done
    echo "::error::$IMAGE never reached the registry; Deploy's build did not finish"
    exit 1
```
*Live from [`.github/workflows/deploy-cosmos.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy-cosmos.yml#L114-L128), region `wait-for-image`, read from this build at 1d6b50b.*

The image's slow layers, `npm ci`, the restore and the publish, are kept in
GitHub's own build cache between runs through Buildx, so a version whose
packages did not change skips them. The cache is GitHub's and not the
registry's: the registry holds the images and nothing else, and its size is
Steve's to prune.

```yaml
# The image's slow layers (npm ci, dotnet restore, the publish) are kept
# in GitHub's own build cache between runs, not in the registry, so a
# version whose packages did not change skips them and the registry holds
# nothing but the images themselves (ADR-009, addendum of 21 September).
- name: Sign in to the registry
  run: az acr login --name "$REGISTRY_NAME"

- uses: docker/setup-buildx-action@v4

- name: Build and push
  id: build
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: ${{ env.IMAGE }}
    build-args: |
      APP_VERSION=${{ env.APP_VERSION }}
      APP_COMMIT=${{ env.APP_COMMIT }}
      GOOGLE_SITE_VERIFICATION=${{ vars.GOOGLE_SITE_VERIFICATION }}
    cache-from: type=gha,scope=theyard
    provenance: false
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L123-L144), region `build-cache`, read from this build at 1d6b50b.*

## Addendum, 2026-09-21: the cache is written after the roll

The build step used to write every layer to the build cache (mode=max) before it returned, and the roll waited on it. On f5b81c4 the second site found the new tag in the registry 59 s before this site began to roll: that minute was the cache upload. The build now reads the cache and pushes the image, the site rolls and answers, and a last step builds the same thing again, all cache hits on the same runner, and writes the cache for the next version. A failed roll still writes it.

```yaml
# The build cache is written after the site answers, not inside the build:
# exporting every layer (mode=max) took about a minute between the image
# reaching the registry and the roll starting (measured on f5b81c4: the
# second site found the tag 59 s before this site began to roll). The same
# build again is all cache hits on this runner, so this step only uploads.
# It runs on a failed or a cancelled run too, once the image was built:
# always() is what reaches a cancelled run, and a run cancelled before
# the build has nothing new to keep (ADR-009, addendum of 29 September).
- name: Keep the build cache
  if: always() && steps.build.outcome == 'success'
  uses: docker/build-push-action@v7
  with:
    context: .
    push: false
    outputs: type=cacheonly
    build-args: |
      APP_VERSION=${{ env.APP_VERSION }}
      APP_COMMIT=${{ env.APP_COMMIT }}
      GOOGLE_SITE_VERIFICATION=${{ vars.GOOGLE_SITE_VERIFICATION }}
    cache-from: type=gha,scope=theyard
    cache-to: type=gha,scope=theyard,mode=max
    provenance: false
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L317-L338), region `cache-export`, read from this build at 1d6b50b.*

## Addendum, 2026-09-25: the registry keeps the newest ten images

Steve asked for the last ten deployments to be kept and older ones pruned by default. The registry held 212 tagged images and 23 untagged manifests, 14.1 GB against the Basic plan's 10 GiB, one image for every version since 31 August. The first choice was ACR's own purge as a scheduled ACR task; the subscription refuses ACR Tasks (`TasksOperationsNotAllowed`), so the pipeline prunes instead, in two places. After the site answers, `deploy.yml` reads the image list newest first, keeps the ten newest tagged images, and deletes every older one and every untagged manifest; it deletes nothing if it reads fewer than ten, or if either site runs an image outside the ten, and a refusal to delete marks the step and never fails the deploy. The deploy's identity holds AcrPush, which pushes and cannot delete, so that step deletes only once the identity is also granted AcrDelete on the registry, a grant that is Steve's to make. Until then, and after it as a second line, the ship script on the machine that makes every push runs the same rule after the roll with the owner's sign-in. The registry was pruned to ten by hand the same morning (tweaklane, queue 1425: 216 of 225 manifests deleted, 14.1 GB to 2.1 GB). `DeployWorkflowTests` holds the step.

## Addendum, 2026-09-25: a prune that is refused is a warning

Until the deploy identity holds AcrDelete, the prune's first delete is refused, and the step went red with its orange mark on every deploy; a mark that is always there teaches a reader to skip it. From 1.0.3.26 a refused delete prints a warning naming the missing grant and ends the step green, and a site running an image outside the ten is a warning too. The step has a five-minute limit of its own, since `continue-on-error` does not reach the job's. Two tests hold what the step assumes: the second site's name in the prune is the one its own workflow deploys to, and every image build sets `provenance: false` with no second platform, so an untagged digest is a whole image and never the child of one of the ten.

## Addendum, 2026-09-29: the latest push wins

Steve asked for the fastest release cycle possible, so releases can stack instead of shipping one at a time, with the pipeline always taking the latest push. Both workflows held their concurrency group with `cancel-in-progress: false`, so a second push to main waited in the queue behind the roll in flight and the site was rolled twice in a row to reach the same place: once to the version nobody needed any more, then to the newest.

From 1.0.3.40 both groups cancel. A newer push cancels the run in flight, queued or rolling, and that run's successor rolls the newest commit. Nothing that decides whether a version is live moved: Verify waits for the version its own run built, at the origin and at the domain, so a roll cut off between its two calls (the settings, then the image) is finished by the next run inside its own timeout. Deploy Cosmos waits for its own image tag, so its cancelled run never rolls the second site to an image the first site skipped.

```yaml
# The latest push wins (ADR-009, addendum of 29 September). A newer push to
# main cancels the run in flight, queued or rolling, and rolls the newest
# commit, so lanes stack their pushes and the site is never rolled twice in a
# row to reach the same place. Verify waits for the version it built, so a
# roll cut off between its two calls is finished by the next run.
concurrency:
  group: deploy-production
  cancel-in-progress: true
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L34-L41), region `latest-push-wins`, read from this build at 1d6b50b.*

Two steps must still run on a cancelled run: the build cache, so the next version's build finds its layers, and the prune that keeps the registry to ten images. `always()` is the condition GitHub evaluates on a cancelled run; each step also asks whether the build step finished, because a run cancelled before its image was built has no new layers to keep and no new image to count. The proof is the second push of 29 September, read off the Actions list: one run cancelled, one complete, both domains on the second version.

```yaml
# The build cache is written after the site answers, not inside the build:
# exporting every layer (mode=max) took about a minute between the image
# reaching the registry and the roll starting (measured on f5b81c4: the
# second site found the tag 59 s before this site began to roll). The same
# build again is all cache hits on this runner, so this step only uploads.
# It runs on a failed or a cancelled run too, once the image was built:
# always() is what reaches a cancelled run, and a run cancelled before
# the build has nothing new to keep (ADR-009, addendum of 29 September).
- name: Keep the build cache
  if: always() && steps.build.outcome == 'success'
  uses: docker/build-push-action@v7
  with:
    context: .
    push: false
    outputs: type=cacheonly
    build-args: |
      APP_VERSION=${{ env.APP_VERSION }}
      APP_COMMIT=${{ env.APP_COMMIT }}
      GOOGLE_SITE_VERIFICATION=${{ vars.GOOGLE_SITE_VERIFICATION }}
    cache-from: type=gha,scope=theyard
    cache-to: type=gha,scope=theyard,mode=max
    provenance: false
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L317-L338), region `cache-export`, read from this build at 1d6b50b.*

**Measured on the proof, 29 September.** 1.0.3.40 (8c6f416) was pushed at 08:44:28 CDT and 1.0.3.41 (31a5f44) at 08:50:13, inside its roll. Both 1.0.3.40 runs ended cancelled in their Verify step, after the image was built and both sites had been handed it. On the cancelled Deploy run, `Keep the build cache` (15 s) and `Keep the newest ten images` (5 s) both ran and succeeded, so `always()` is what the runner honours on a cancel, with the build step's outcome as the guard. The 1.0.3.41 runs started when the cancelled run finished those two steps, about forty seconds after the push, and both sites served 1.0.3.41 at 31a5f44 by 08:59:34, nine minutes and twenty-one seconds after its push. One roll's wait was saved: before this the second push would have queued behind the whole of the first roll.

## Addendum, 2026-09-29: the sample rolls on its own

Steve asked for the sample to deploy alongside TheYard, living under samples and sharing only the Bicep and the App Service plan. A developer test project was built under `samples/theshed` as The Shed, a solution of its own inside this repository. It deploys to a third web app on the plan the two sites share, from its own workflow, `deploy-shed.yml`, which runs when a push touches that folder: the version from the sample's own changelog, one image into the same registry, the site described by the sample's own Bicep in incremental mode (an existing plan and identity, one new site, nothing else in the group touched), and a verify that waits for the new build to answer at the origin.

The two site deploys ignore `samples/**` and the sample's workflow file, so a push that changes only the sample does not roll TheYard, and a push that changes both rolls both. The repository tests skip the folder (`Repo.NotOurs`): the sample holds its own copies of the house rules, in its own suite, and this suite reads TheYard. The sample's records say what it borrowed and what it left behind.

**Measured on the first run, 29 September (1.0.3.46).** The workflow's first version asked Azure to deploy the sample's Bicep on every run, and the deploy identity may not: it holds Website Contributor on each site and nothing at the group, which is the least it needs to roll (ADR: The code is public and the secrets are not). So the site was created once, from the same Bicep, by the owner from the runner, the identity was given the same role on the new site, and the workflow rolls it the way `deploy.yml` rolls: the settings, then the image. One first run failed at that step and nothing was rolled; the record and the workflow say why.

```yaml
name: Deploy The Shed

# The sample under samples/theshed, rolled to its own web app on the plan the
# two TheYard sites share (samples/theshed/docs/ADR-001). Runs only when the
# sample changes; TheYard's own deploys ignore samples/ for the same reason.
# The steps are the ones deploy.yml uses, shorter: the version from the
# sample's changelog, one image into the same registry, the site described by
# the sample's own Bicep (incremental, so nothing else in the group is touched),
# and a verify that waits for the new build to answer.
on:
  push:
    branches: [main]
    paths:
      - "samples/theshed/**"
      - ".github/workflows/deploy-shed.yml"
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

concurrency:
  group: deploy-shed
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    env:
      REGISTRY_NAME: crtheyardsszmnetj67bn5h2
      ACR: crtheyardsszmnetj67bn5h2.azurecr.io
      RG: RG-THEYARD-SS
      APP: APP-THESHED-SS-ZMNETJ67BN5H2
      ORIGIN: https://app-theshed-ss-zmnetj67bn5h2.azurewebsites.net
      SITE: https://theshed.stevenstout.biz
      SAMPLE: samples/theshed
    steps:
      - uses: actions/checkout@v7

      - name: Compute the version
        run: |
          set -eu
          N=$(grep -m1 -oE '^- [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "$SAMPLE/docs/CHANGELOG.md" | sed 's/^- //')
          if [ -z "$N" ]; then
            echo "::error file=$SAMPLE/docs/CHANGELOG.md::No version line at the top of the changelog"
            exit 1
          fi
          {
            echo "APP_VERSION=$N"
            echo "APP_COMMIT=${GITHUB_SHA:0:7}"
            echo "IMAGE=$ACR/theshed:v$N"
          } >> "$GITHUB_ENV"
          echo "Deploying The Shed $N from ${GITHUB_SHA:0:7} as $ACR/theshed:v$N"

      - uses: azure/login@v3
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Sign in to the registry
        run: az acr login --name "$REGISTRY_NAME"

      - uses: docker/setup-buildx-action@v4

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: samples/theshed
          push: true
          tags: ${{ env.IMAGE }}
          build-args: |
            SHED_COMMIT=${{ env.APP_COMMIT }}
          cache-from: type=gha,scope=theshed
          cache-to: type=gha,scope=theshed,mode=max
          provenance: false

      # The site was created once from the sample's Bicep by a person with rights on
      # the group (samples/theshed/infra/site.bicep, the header says how); the deploy
      # identity holds Website Contributor on the site and nothing wider, the same as
      # on the two TheYard sites, so a roll here is the two calls deploy.yml makes:
      # the settings, then the image. App Service starts the new container beside
      # the old one and moves traffic when it answers.
      - name: Roll
        run: |
          set -eu
          az webapp config appsettings set -g "$RG" -n "$APP" --settings "SHED_COMMIT=$APP_COMMIT" -o none
          az webapp config set -g "$RG" -n "$APP" --linux-fx-version "DOCKER|$IMAGE" -o none
          az webapp show -g "$RG" -n "$APP" --query "{state:state, image:siteConfig.linuxFxVersion, host:defaultHostName}" -o json

      - name: Verify
        run: |
          set -eu
          for i in $(seq 1 60); do
            sleep 5
            body=$(curl -fsS --max-time 10 "$ORIGIN/api/version" || true)
            if [[ "$body" == *"\"$APP_VERSION\""* && "$body" == *"\"$APP_COMMIT\""* ]]; then
              echo "origin serves $APP_VERSION at $APP_COMMIT after $((i * 5))s: $body"
              break
            fi
            if [[ "$i" -eq 60 ]]; then
              echo "origin never served $APP_VERSION at $APP_COMMIT (last answer: $body)"
              exit 1
            fi
          done
          curl -fsS --max-time 20 "$ORIGIN/healthz"
          echo
          curl -fsS --max-time 20 "$ORIGIN/api/files" | head -c 400
          echo
          # The public name, once its DNS exists; until then a warning, not a failure.
          site=$(curl -fsS --max-time 30 "$SITE/api/version" || true)
          if [[ "$site" == *"\"$APP_VERSION\""* ]]; then
            echo "domain: $site"
          else
            echo "::warning::$SITE does not serve $APP_VERSION yet (DNS or the certificate is not in place): $site"
          fi
```
*Live from [`.github/workflows/deploy-shed.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy-shed.yml#L1-L117), region `*`, read from this build at 1d6b50b.*


## Addendum, 2026-10-08: sizing Verify from the rolls it reads

Deploy #282 for 1.0.3.98 reported failure at its Verify step while both domains rolled fine and every read after the roll passed. Verify gave the new build ten minutes from the roll to answer `/api/version`, with ten seconds a read. The container started 8 min 15 s after the image push, and the Azure SQL site was ready 137 s later, 24 s past the budget. The edge warm after it was skipped, so the first visitor paid for the edge misses too.

The rolls since 1.0.3.95 give the size. The new container starts 8 to 12 minutes after the image push and is ready 2 to 3.5 minutes after that (ADR-095, the roll-by-roll table). From 1.0.3.99 each deploy waits twenty-five minutes for the version, counted on the shell's clock and not in loop turns, so a slow read cannot stretch it. Each read may take thirty seconds, because an API read waits for the catalogue to load (ADR: The ports learn to wait). The wait for readiness after it keeps its ten minutes. Each job's timeout moves from thirty minutes to fifty, so the job outlasts every wait in it: the build or the wait for the image, then twenty-five minutes for the version and ten for readiness.

```yaml
# A deploy is not done when Azure returns; it is done when the new build
# answers. This waits for the version it just built, at the origin and then
# at the domain, and fails the run if it never appears, so a bad roll
# cannot be reported as a good one.
- name: Verify
  run: |
    # Wait for the NEW build to answer at the origin. The plan is one shared
    # processor, and the image has to be pulled and started on it before the
    # new build answers. Every read
    # here carries the self mark on its agent, so the Site activity card
    # counts it as the site's own (ADR: Site activity, 24 September).
    # Twenty-five minutes, read off the rolls: the new container starts 8 to 12
    # minutes after the image push and is ready 2 to 3.5 minutes after that, and
    # a ten-minute loop ended a roll that worked (ADR: The deploy pipeline, the
    # addendum on sizing Verify). Thirty seconds a read, because an API read
    # waits for the catalogue to load.
    budget=1500
    started=$SECONDS
    body=""
    until [[ "$body" == *"\"$APP_VERSION\""* ]]; do
      if (( SECONDS - started >= budget )); then
        echo "origin never served $APP_VERSION in ${budget}s (last answer: $body)"
        exit 1
      fi
      sleep 5
      body=$(curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$ORIGIN/api/version" || true)
    done
    echo "origin serves $APP_VERSION after $((SECONDS - started))s: $body"
    # Readiness is the container's own word that its files are in place and its
    # catalogue has loaded (ADR-010). The process listens before the catalogue is
    # in (ADR: The ports learn to wait, the addendum on listening first). An API
    # read waits for the load, so the version above usually arrives with it;
    # this wait says so in the container's own words before the filters are read.
    for i in $(seq 1 120); do
      if curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 10 "$ORIGIN/readyz" > /dev/null 2>&1; then
        echo "origin ready $(((i - 1) * 5))s after it served the version"
        break
      fi
      if [[ "$i" -eq 120 ]]; then
        echo "origin never said ready"
        exit 1
      fi
      sleep 5
    done
    curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 20 "$ORIGIN/api/facets" > /dev/null
    echo
    # The domain is the edge in front of the same origin: what a visitor sees.
    site=$(curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$SITE/api/version")
    echo "domain: $site"
    if [[ "$site" != *"\"$APP_VERSION\""* || "$site" != *"\"$APP_COMMIT\""* ]]; then
      echo "domain does not report $APP_VERSION at $APP_COMMIT"
      exit 1
    fi
    curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$SITE/api/facets" > /dev/null
    # Which store the container actually came up on, said out loud.
    #
    # A warning and not a failure, on purpose. The whole point of the
    # file-backed fallback is that the site serves when the database does
    # not (ADR: The relational store), so a roll that lands on SQLite is
    # the design working and must not fail a deploy. What it must not be
    # is silent: 1.0.0.49 shipped that way and nothing said so until
    # somebody read the health endpoint by hand.
    store=$(curl -fsS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$SITE/api/health" || true)
    case "$store" in
      *"Azure SQL Database"*) echo "the site is on Azure SQL Database" ;;
      *SQLite*) echo "::warning::this roll is serving from SQLite, not Azure SQL Database" ;;
      *) echo "::warning::could not tell which store the container is on" ;;
    esac
    # Azure's own view of the container (the Admin tab's card), informational.
    curl -sS --max-time 40 "$SITE/api/admin/azure" || true
    echo
    {
      echo "## Deployed $N"
      echo "- commit: $APP_COMMIT"
      echo "- image: $IMAGE"
      echo "- site: $SITE"
    } >> "$GITHUB_STEP_SUMMARY"
```
*Live from [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy.yml#L224-L300), region `verify`, read from this build at 1d6b50b.*

`DeployWorkflowTests` holds the numbers on both workflows: a budget of 1,500 seconds named in Verify, thirty seconds on the version read, and a job timeout that covers the ten minutes before Verify plus its thirty-five. A roll that never answers still fails the run; it now fails at twenty-five minutes instead of ten.

## Addendum, 2026-10-17: the runner is a version, and a wait is a fact

Two changes to how the pipeline runs, neither of them to what it builds.

GitHub says the `ubuntu-latest` label moves to Ubuntu 26 from 19 October 2026. A label that moves is a build that changes without a commit, which is the one thing a pipeline is for preventing, and it would have moved while nobody was watching it. Every workflow here now names `ubuntu-24.04`, the image they have been green on, so the migration is a change this repository makes on a day it chooses. The Shed's suite on `windows-latest` is left as it is: the notice is about the Ubuntu label, and that job exists to run the path rules on Windows, whichever Windows GitHub offers.

The browser suite failed twice on `main` on the morning of 17 October and passed on every push after it, which is the shape of a wait that is a guess rather than a test. Both places that waited on `networkidle` now wait on what they are actually waiting for: the landing page's shift reading waits until every figure the first paint fetches has drawn, and the Admin tab's request test waits until the tab has made a request. Playwright's own documentation discourages `networkidle` for this reason. Neither timeout was raised, no test was retried, and nothing was skipped.
