# CI/CD

How code gets from a laptop to https://theyard.stevenstout.biz, written for a
reviewer or anyone learning how small projects ship safely.

## In plain words

This page shows how a code change gets from a laptop to the live site. Every test runs before a version can ship (the ship's gate), and a push to the main branch then builds the API's container image beside the rendering service's and rolls each onto its web apps with no human step.

What that is worth: a developer can merge knowing a failing test stops the change before it reaches anyone, and the organization ships often with no Azure password or key stored anywhere to leak.

## What runs today

**Continuous integration.** Every test runs once per version, in the ship's
gate on the machine that ships: the API suite on both stores, the frontend
suite, and a browser suite that clicks through the running app the way a
person would. A change that fails any of them is not committed, so it never
reaches GitHub. Every result, test by test, ships with the version and is on
the Admin tab. The same suites then run again on GitHub's runners for every
push to main, where anybody can read the result without a login, and for a
pull request from anybody else, which has had no gate.

![The CI workflow runs on GitHub Actions, every one green, forty-one of them by the end of the second day](/api/docs/images/github-ci-runs.jpg#1280x828)

**Continuous deployment.** When the gate pushes to main, a workflow named
Deploy builds the container image, pushes it to Azure Container Registry,
and rolls it onto the first site's web app, and on the same push a second
named Deploy Cosmos waits for that image and rolls it onto the second site's,
the two rolls side by side, with no human in the loop. A third, Deploy Render,
fires on the same push: it builds the rendering service's own image from
`render/Dockerfile` as the same version, pushes it as `theyard-render`, rolls
the service's web app, and is done when the service answers its readiness as
that version. The service draws a page only for an API running its own
build, so the three rolls need not land together: until a site's API and the
service agree, that site's pages are drawn in the browser, as they were
before the service existed (ADR: A rendering service beside the API). All
three web apps share one App Service plan (ADR: One plan, two sites). Each workflow signs in to Azure with a token GitHub mints for this one
repository's main branch, so no Azure password or key is stored anywhere. The
version in the page footer is stamped by that build. The full record is
ADR: The deploy pipeline, below this entry in the menu.

![The Deploy workflow's runs on GitHub Actions, one green run per version on the live site](/api/docs/images/github-deploy-runs.jpg#1280x758)

One run, step by step: compute the version, sign in to Azure with the
minted token, build and push the image, roll the site, verify the origin
and the domain. Every version on the site has a page like this. The picture
is from the weeks the step was called "Roll the container group"; since
1.0.0.156 it rolls a web app and is called "Roll the site".

![One Deploy run's steps: compute the version, sign in to Azure, build and push, roll the container group, verify](/api/docs/images/github-deploy-steps.jpg#1280x728)

Deploy Render's roll and its check, read here from the workflow in this
build. The roll is one call, because every setting the service runs with is
in `infra/render.bicep` and only the image changes; the check waits until the
service answers its readiness as the version it was built as, and says so
when a site's API has not caught up yet:

```yaml
# The image is the only thing a roll changes; the settings are infra/render.bicep's.
- name: Roll the renderer
  run: |
    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}" -o json
```
*Live from [`.github/workflows/deploy-render.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy-render.yml#L94-L98), region `render-roll`, read from this build at 1d6b50b.*

```yaml
# Done when the new renderer answers its own readiness with the version it
# was built as. Its readiness also says, per site, whether that site's API
# runs the same build; a site that does not yet is a notice and not a
# failure, because its own deploy may still be rolling.
- name: Verify
  run: |
    budget=1500
    started=$SECONDS
    body=""
    until [[ "$body" == *"\"version\":\"$APP_VERSION\""* ]]; do
      if (( SECONDS - started >= budget )); then
        echo "the renderer never reported $APP_VERSION in ${budget}s (last answer: $body)"
        exit 1
      fi
      sleep 5
      body=$(curl -sS -A "TheYard-SelfRead/1 (deploy)" --max-time 30 "$ORIGIN/readyz" || true)
    done
    echo "the renderer answers as $APP_VERSION after $((SECONDS - started))s: $body"
    if [[ "$body" == *'"same_build":false'* ]]; then
      echo "::notice::a site's API does not run $APP_VERSION yet; its pages draw in the browser until it does"
    fi
```
*Live from [`.github/workflows/deploy-render.yml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/.github/workflows/deploy-render.yml#L102-L122), region `render-verify`, read from this build at 1d6b50b.*

**The edge deploys itself.** The HTTPS front door of this site (see the
Hosting menu) is three files in this repository. Netlify watches the repo and
redeploys the edge only when those files change, with no human in the loop.
The rule that keeps an app push from redeploying three unchanged files is in
the edge's own configuration:

```ini
# Only rebuild the edge when the edge itself changed. App pushes were
# burning 15 Netlify credits each on redeploys of three unchanged files;
# exit 0 here means "nothing under edge/ or this file moved, skip it".
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- edge/ netlify.toml"
```
*Live from [`netlify.toml`](https://github.com/SteveStout/TheYard/blob/1d6b50b/netlify.toml#L4-L7), region `ignore-rule`, read from this build at 1d6b50b.*

## The design, before the build

Deployment automation landed 2026-09-02 as a second GitHub Actions
workflow, and this is the design it was built from, written the day before
on purpose: the plan is a deliverable too.

- A separate Deploy workflow fires only when CI completes green on
  main, and checks out the exact commit CI tested.
- It builds the image with the two footer build arguments, so the page
  keeps reporting exactly what is running (see ADR: Version in the
  footer under Best Practices).
- It pushes to the registry and rolls the container group from a
  template checked into infra/, the same file every deploy uses.
- Authentication is OIDC federated credentials: Azure trusts a token
  GitHub mints for this one repo's main branch. No password or key gets
  the pipeline into Azure. One secret exists, the session signing key the
  roll hands to both containers so a roll does not sign everybody out; it
  is masked in every log, rotating it is one roll, and a roll without it
  invents a key at startup and says so (ADR: Three readers with no memory
  of the project).
- Roles are scoped to least privilege: push to this one registry,
  manage container groups in this one resource group, assign this one
  identity.
- Displayed versions are read from the changelog's top line, so the
  footer and the file that documents the footer cannot disagree. One
  number is missing, 1.0.0.39: it was skipped under the old run-counter
  formula, and a gap is a change that never shipped.
- The manual scripted pipeline retires to fallback duty and stays
  documented: it is also the rollback.

The identity, as Azure lists it. The federated credential trusts one issuer
for one subject (the owner and repository ids inside it mean a renamed or
re-created repo cannot inherit the trust), and the three role assignments
are the whole of what the pipeline can do:

```text
name:      github-main
issuer:    https://token.actions.githubusercontent.com
subject:   repo:SteveStout@317307255/TheYard@1352398185:ref:refs/heads/main
audiences: api://AzureADTokenExchange

Role                                        Scope
------------------------------------------  ------------------------------------------------
AcrPush                                     .../registries/crtheyardsszmnetj67bn5h2
Azure Container Instances Contributor Role  .../resourceGroups/RG-THEYARD-SS
Managed Identity Operator                   .../userAssignedIdentities/id-theyard-ss
```

Since 20 September 2026 the same identity also holds Website Contributor on the two web apps, and since
8 October on the rendering service's as well, and on nothing wider: setting a site's image and its settings is the whole of a roll
(ADR: One plan, two sites; ADR: A rendering service beside the API).

## What comes next

Deployment slots for a zero-downtime roll, which the production design under
Hosting already covers with App Service, and the registrar transfer around
the end of October that retires the Netlify edge. Neither changes the
pipeline: the image and the workflow stay the same, only the target moves.

## Why it is built in this order

Tests first, automation second. A pipeline that ships untested code is a
faster way to break production. The test wall existed before this site had a
URL, and every deploy so far has been gated on it.

## Files

- [`.github/workflows/ci.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/ci.yml): a style job, then the three suites and the SQL project build as four jobs behind it, on every push to main and on a pull request.
- [`.github/workflows/deploy.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy.yml): the Deploy workflow, step by step.
- [`.github/workflows/deploy-cosmos.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy-cosmos.yml): the second site's roll, from the same image.
- [`.github/workflows/deploy-render.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/deploy-render.yml): the rendering service's build and roll, on the same push.
- [`infra/appservice.bicep`](https://github.com/SteveStout/TheYard/blob/main/infra/appservice.bicep): the plan and the two sites Deploy rolls (the roll sets the image and four settings).
- [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/main/Dockerfile): the image Deploy builds.
- [`render/Dockerfile`](https://github.com/SteveStout/TheYard/blob/main/render/Dockerfile): the image Deploy Render builds: the service's build on Node, nothing else.
- [`docs/CHANGELOG.md`](https://github.com/SteveStout/TheYard/blob/main/docs/CHANGELOG.md): the line every version writes for itself.
