ADR-020 — Build once on GitHub, deploy many by digest
Status: Accepted · Date: 2026-10-06 · PRs: #742 (build once), #746 (digest, pins, scans) ·
Pipeline: delivery.md
Context
Until #742 the self-hosted runner built every image on the production box on every deploy:
8–9 min of prod CPU, and a deploy that depended on npm, NuGet, MCR, Docker Hub and Chrome downloads
from the server. #742 moved the build to a GitHub-hosted runner, scanned the images for secrets and
pushed them to GHCR as textstack-<svc>:<sha>; the server pulled that tag.
The 2026-10-06 delivery review found two gaps in that design. A tag is mutable: anything with
packages: write could re-push :<sha> between build and pull, and the server would run it unscanned.
And the bases — the prod DB, the backup tool that sees the R2 keys, the scanner itself — were pulled by
mutable tags too. It also measured where the 15 minutes of images go: 12 of them scale with image
size (disk cleanup, scan, push), not with build work.
Decision
- Build once, on GitHub.
images.ymlbuilds every compose image for the exact commit, scans it (scripts/scan-image-secrets.sh, canary.envfrom.env.example+ every compose${VAR}), and only then pushes. - Deploy by digest.
imagesoutputs<svc>@sha256:<digest>fromdocker pushitself — the bytes it scanned.deploypullsname@digestand tags it:<sha>locally for compose and humans. No digest, or a failed pull → the server builds, as before. A failedimagesnever blocks a deploy; a failedcior pre-deploy backup does. The backup is a step indeployafter the SSG wait and before the web build — before anything live changes, so a failed dump ships nothing, and minutes before the migrator. Not a parallel job: a dump taken at the start of the run can be an hour older than the migration it is meant to undo (CI, images, the SSG wait). Correctness of the rollback point beats ~2.5 min. - Everything third-party is pinned to content: actions by commit SHA (
# vX.Ycomment), pulled images bytag@sha256. Dependabot (github-actions,docker,docker-compose) moves the pins, so a pin is not a freeze. - Every artifact users download is scanned: images (gate before push),
apps/web/dist(gate before push on GitHub, and again before the swap on the server — since 2026-10-07; after the server build only on the fallback), the OTA bundle (gate beforeeas update).
Alternatives
| option | verdict | why |
|---|---|---|
| Build on the server again | Rejected | ~14 min (~11 even slim) vs ~8–9; prod CPU during deploys; and with nothing published the scan has no reason to exist. |
| buildx registry cache | Rejected | The build is already ~2 min warm with the gha cache. The cost is image size, fixed by slimming. |
| Per-image rebuild + re-tag unchanged images from the previous SHA | Deferred | Saves 0 min while images runs parallel to a ~6 min ci. “Previous SHA” can ship stale code (failed or cancelled runs, rollbacks, shared inputs, baked GIT_SHA). If ever needed: content-hash tags over each image’s inputs, SENTRY_RELEASE moved to runtime, and a guard test on Dockerfile COPY sources. |
| cosign signatures / build provenance | Skipped | The threat here is a tag swap; pulling by digest closes it with no keys to manage. One owner, one server. |
| Pre-deploy dump in a parallel job | Rejected | Saves ~2.5 min, but moves the rollback point up to an hour before the migration; writes in that window are lost on restore. |
| SBOM | Skipped | --sbom=true is free, but nobody would read it. Revisit if a customer asks. |
| Private GHCR packages | Skipped | Costs money at today’s sizes; the repo is public anyway. Images carry no secrets by construction and by scan. |
Consequences
- The server needs only
docker pullfrom GHCR for a normal deploy; build tools stay as the fallback. - A digest bump on a base is a Dependabot PR that CI and the scan gate like any change.
- Pins inside scripts and
run:lines (scanner, restic, Makefile alpine) are invisible to Dependabot and are bumped by hand. - Published versions pile up (six per merge);
ghcr-retention.ymlprunes them weekly but never the deployed SHA or the last 5 deploys, so a rollback to a recent deploy still pulls by digest. - The web frontend follows the same path (2026-10-07, review P2-2):
images.ymljobwebbuildsapps/web/diston GitHub with canary env, scans it, pushes aFROM scratchimagetextstack-web:<sha>; the deploy pulls it by digest, copies/distout, scans it with the real values and swaps it in file by file (scripts/swap-web-dist.sh;distis bind-mounted by ssg-worker, so never renamed). A scan hit stops the deploy before anything is live, and npm no longer runs on the production host — except on the fallback server build, kept as for images. - First deploy after the pins:
db,ollama(andaspire-dashboardif its profile is up) are recreated because their image reference changed — a few seconds of DB restart inside the deploy, after the pre-deploy dump.