Skip to content

chore(schema): generate PostgreSQL schema documentation #1082

chore(schema): generate PostgreSQL schema documentation

chore(schema): generate PostgreSQL schema documentation #1082

Workflow file for this run

name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
# Repair path (#351). A commit whose message contains `[skip ci]` — which
# GitHub matches ANYWHERE in the message, and which reaches the squashed
# release commit if any changelog entry happens to contain it — triggers no
# push run at all. There is then no CI run for that commit and no image, so a
# release drafted at it can never be promoted, and re-running is impossible
# because no run exists to re-run.
#
# This rebuilds and publishes for one explicit commit, through the same gates.
# It is deliberately NOT a general "publish anything" button: the commit must
# already be an ancestor of main (checked below), so it can only ever republish
# history that was already merged.
workflow_dispatch:
inputs:
sha:
description: "Commit on main to build and publish, e.g. to repair a release whose CI run was skipped"
required: true
type: string
permissions:
contents: read
jobs:
build-and-test:
name: Build and test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
# On a repair dispatch, gate the commit that was asked for. Empty on
# push/PR, which actions/checkout treats as "use the default ref".
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.sha || '' }}
- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: "**/*.csproj"
# --locked-mode: restore must match the committed packages.lock.json
# exactly, so a dependency can't float to a different resolved version
# between a green local run and CI. Fails with NU1004 when a package was
# added/bumped without regenerating — run `dotnet restore` and commit the
# updated lock files (#146).
- name: Restore dependencies
run: dotnet restore Cluckwork.sln --locked-mode
# BLOCKING gate (#146). `dotnet list package --vulnerable` always exits 0,
# so the script parses its JSON and fails the job on any advisory at high or
# above. After restore (it needs the resolved graph), before build, so a
# vulnerable dependency is caught even when compilation would fail later.
# `--output-version 1` pins the JSON schema so a future CLI bump can't
# silently change the shape the parser depends on.
- name: Audit NuGet dependencies (high+, blocking)
run: |
dotnet list package --vulnerable --include-transitive --format json --output-version 1 \
| node .github/scripts/vuln-gate.mjs --ecosystem nuget --level high
- name: Build
run: dotnet build Cluckwork.sln --configuration Release --no-restore
- name: Test
run: dotnet test Cluckwork.sln --configuration Release --no-build --verbosity normal
# #417 — the committed docs/schema/ must be exactly what the migrations
# produce: the script regenerates from an ephemeral migrated Postgres
# (same digest-pinned image Testcontainers just pulled for the tests,
# so the pull is warm) and byte-diffs against the committed docs.
# Reuses the Release build from the steps above. Rationale + the
# deliberate everything-runs-it trade-off: docs/decisions/417-schema-docs.md
- name: Verify schema docs are current
run: tools/schema-docs/generate.sh --check
web:
name: Web typecheck, test, and build
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: web
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.sha || '' }}
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
# Resolved from the repo root — setup-node ignores defaults.run.working-directory.
cache-dependency-path: web/package-lock.json
# The gate ships with its own node:test self-tests; run them before trusting
# its verdict. No app toolchain needed — pure Node, so it runs before npm ci.
# Referenced via ../ from the job's web working-directory; node:test imports
# are relative to the test file, so the cwd doesn't matter.
- name: Test the vulnerability gate
run: node --test ../.github/scripts/vuln-gate.test.mjs
- name: Test the lockfix classifier
run: node --test ../.github/scripts/lockfix.test.mjs
- name: Install dependencies
run: npm ci
# BLOCKING gate (#146) on PRODUCTION dependencies: shipped code carries no
# known high+ advisory. `--omit=dev` scopes it to what reaches the browser.
#
# `npm audit` exits non-zero whenever it finds ANY vulnerability, at any
# severity. Under Actions' default `pipefail`, piping it straight into the
# gate would let npm's exit — not the gate's threshold — decide the step, so
# a below-high advisory would wrongly fail it. Capture to a file (`|| true`
# drops npm's exit) and let the gate be the sole arbiter.
# `--exceptions ../…`: this job runs from `web/`, so the gate's default
# (repo-root) exceptions path would resolve to a nonexistent
# `web/.github/…` and silently drop every npm exception — leaving an
# excepted advisory blocking anyway (#146 review). Point it at the real file.
- name: Audit prod npm dependencies (high+, blocking)
run: |
npm audit --omit=dev --json > npm-audit-prod.json || true
node ../.github/scripts/vuln-gate.mjs --ecosystem npm --level high \
--exceptions ../.github/security-exceptions.json < npm-audit-prod.json
# ADVISORY only (never blocks) on the full tree, dev dependencies included.
# A build-tooling advisory (vite, vitest, eslint…) does not ship to users,
# so it surfaces in the log without wedging unrelated PRs. Bump the dep, or
# promote this to a blocking step, when one actually appears.
- name: Audit all npm dependencies (moderate+, advisory only)
run: |
npm audit --json > npm-audit-all.json || true
node ../.github/scripts/vuln-gate.mjs --ecosystem npm --level moderate --warn-only \
--exceptions ../.github/security-exceptions.json < npm-audit-all.json
- name: Test with coverage gate
run: npm run test:coverage
- name: Typecheck and build
run: npm run build
# #142 — assert the guarantees of the GENERATED service worker, not just
# the config meant to produce it. The unit tests mock
# navigator.serviceWorker entirely and the .NET tests use a placeholder
# sw.js, so without this a narrowed denylist regex or an added
# runtimeCaching rule would ship with every other check green, silently
# letting the worker answer /api from cache.
- name: Verify service-worker guarantees
run: npm run verify:sw
# PR-only: fails when a PR ADDS a dependency with a known vulnerability, before
# it lands on main. Complements the audit gates (which score the whole tree) by
# scoring the DIFF, so a newly introduced bad dep is named in the PR itself.
dependency-review:
name: Dependency review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Check out repository
uses: actions/checkout@v7
# dependency-review needs the repo's Dependency graph, a one-time owner
# toggle (Settings → Advanced Security → Dependency graph) with no API to
# flip it. Probe for it and branch on the HTTP status: only a definitive
# graph-disabled response (403/404) skips the review — with a loud warning
# naming the setting, so the gate self-activates on the next PR once the
# toggle is on. A transient/unexpected status does NOT skip; the review
# runs and surfaces the real error rather than silently disabling the gate
# (#146 review). The warning fires on every graph-less run, so the gap
# can't hide.
- name: Check the Dependency graph is enabled
id: graph
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
code="$(gh api "repos/$REPO/dependency-graph/sbom" --silent -i 2>/dev/null \
| awk 'toupper($1) ~ /^HTTP/ {print $2; exit}')"
case "$code" in
200)
echo "available=true" >> "$GITHUB_OUTPUT" ;;
403|404)
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::warning title=Dependency review inactive::Enable the Dependency graph at Settings → Advanced Security → Dependency graph; this gate activates automatically once it is on (#146)." ;;
*)
echo "available=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Dependency graph probe inconclusive::sbom probe returned '${code:-no response}'; running dependency-review anyway so a real error is not masked (#146)." ;;
esac
# Same source of truth as the audit gates: the live (unexpired) GHSA ids in
# security-exceptions.json become this action's allowlist, so the two gates
# can never disagree about what is currently excepted. emitAllowlist yields
# only bare, canonical GHSA ids; `tr -d` strips any stray newline as
# defence-in-depth so a single value can never forge a second GITHUB_OUTPUT
# record (#146 review).
- name: Compute allowlist from security exceptions
if: steps.graph.outputs.available == 'true'
id: allow
run: |
ghsas="$(node .github/scripts/vuln-gate.mjs --emit-allowlist | tr -d '\r\n')"
printf 'ghsas=%s\n' "$ghsas" >> "$GITHUB_OUTPUT"
- name: Review dependency changes
if: steps.graph.outputs.available == 'true'
uses: actions/dependency-review-action@v5
with:
fail-on-severity: high
allow-ghsas: ${{ steps.allow.outputs.ghsas }}
# #267 — build the container image and scan it with Trivy. This is also the
# app-side of #266: CI now produces the runtime image, so the same bytes that
# ship are what gets scanned here. The dependency gates above score the source
# tree (NuGet/npm); this scores the assembled image — the OS packages of the
# base image plus the published binaries — which nothing else covers.
image:
name: Image build + Trivy scan
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
# Local image Id of the scanned image, so the publish job can verify the
# artifact handoff carried exactly these bytes (#351).
image_id: ${{ steps.export.outputs.image_id }}
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.sha || '' }}
# Layer cache shared with e2e-smoke.yml (owner call 2026-08-08): both
# workflows build this same Dockerfile per PR on separate runners with no
# daemon in common, which compiled everything twice. The key ends in the
# COMMIT — actions/cache never re-saves an exact-key hit, so a
# dependency-content-only key would freeze the cache at the first
# commit's source layers and every later source change would recompile
# in both jobs against a stale cache forever (codex review of #456).
# Per-commit keys make the second job of a commit a full hit; the
# restore-keys ladder gives a fresh commit the newest same-dependency
# cache (base images + restore layers + unchanged source layers), then
# anything.
- name: Restore image layer cache
uses: actions/cache@v6
with:
path: /tmp/.buildx-cache
key: image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Cluckwork.sln', '.dockerignore') }}-${{ github.sha }}
restore-keys: |
image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Cluckwork.sln', '.dockerignore') }}-
image-layers-
# Build the exact image the container ships: the multi-stage Dockerfile
# compiles the SPA, publishes the API, and runs as the non-root `app` user
# on the digest-pinned base image (#267). buildx with a docker-container
# driver (the daemon's default builder can't export a local cache);
# `--load` puts the result in the local daemon so Trivy scans these
# freshly built bytes, not a re-pull. The rm/mv rotation replaces the
# cache instead of accreting layers into it forever.
- name: Build runtime image
run: |
set -euo pipefail
docker buildx create --driver docker-container --name ci-builder --use
docker buildx build \
--cache-from type=local,src=/tmp/.buildx-cache \
--cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max \
--load -f src/Cluckwork.Api/Dockerfile -t cluckwork-api:ci .
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
# #315 — prove --locked-mode is enforced in the Docker restore: perturb an
# exact-pinned PackageReference WITHOUT refreshing its lock and assert the
# build fails (NU1004). `--target build` stops at the restore/publish stage
# (skips the SPA/web stage), so this is a fast negative check on a throwaway
# copy of the committed tree — the real build above is untouched.
- name: Stale lock fails the Docker restore (--locked-mode drift guard)
run: |
set -euo pipefail
work="$(mktemp -d)"
git archive HEAD | tar -x -C "$work"
cd "$work"
sed -i 's#<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" #<PackageReference Include="Microsoft.OpenApi" Version="2.10.0" #' \
src/Cluckwork.Api/Cluckwork.Api.csproj
# Fail loudly (don't silently pass) if the pin used as the perturbation
# target has since moved — the guard must be kept pointing at a real pin.
grep -q '<PackageReference Include="Microsoft.OpenApi" Version="2.10.0" ' \
src/Cluckwork.Api/Cluckwork.Api.csproj \
|| { echo "::error::drift-guard perturbation target not found; update the Microsoft.OpenApi version it edits"; exit 1; }
if docker build --target build -f src/Cluckwork.Api/Dockerfile -t cluckwork-lockdrift:ci . ; then
echo "::error::Docker restore succeeded with a stale packages.lock.json — --locked-mode is not enforced"
exit 1
fi
echo "OK: a stale lock correctly failed the Docker --locked-mode restore"
# BLOCKING gate: fail the build on a HIGH or CRITICAL CVE — but only one that
# HAS a fix (`ignore-unfixed: true`). That mirrors the #146 dependency-gate
# philosophy: the actionable response to an advisory is "move to a fixed
# version", so a base-OS CVE with no upstream fix yet can't be actioned by a
# bump and must not wedge every unrelated PR. `ignore-unfixed: true` drops
# those from the report entirely — the gate blocks only on FIXABLE
# HIGH/CRITICAL; when a fix ships, Dependabot's `docker` ecosystem bumps the
# pinned base digest and this gate confirms it cleared. Trivy's vuln DB is
# cached across runs (the action's `cache` default), the primary defence
# against the GHCR anonymous-pull TOOMANYREQUESTS that can transiently
# false-fail a PR; the GITHUB_TOKEN env (below) authenticates the pull on a
# cache miss. The action is pinned to a release tag (Dependabot keeps it current).
- name: Scan image for HIGH/CRITICAL vulnerabilities
# Supply-chain: pin the immutable commit, not the re-pointable tag —
# trivy-action was compromised 2026-03 (a repeat incident), so a tag could
# be re-pointed to a malicious release. Dependabot's github-actions
# ecosystem reads the trailing `# v0.36.0` to bump both SHA and comment.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: cluckwork-api:ci
scan-type: image
vuln-type: os,library
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: "1"
format: table
env:
# Trivy reads GITHUB_TOKEN to authenticate its vuln-DB pull from GHCR,
# lifting the anonymous rate limit that would otherwise TOOMANYREQUESTS
# a cache miss. (This action has no token INPUT for the DB — `github-pat`
# is SBOM-submission only — so it goes through the env, which Trivy reads.)
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# #266 — smoke test: prove the freshly built image actually BOOTS and SERVES,
# not merely that it builds and scans clean. Runs the real run-then-exit
# `migrate` verb against a throwaway Postgres, boots the serving container,
# and asserts /health/ready goes green AND the in-container `healthcheck`
# verb (exactly what the Dockerfile HEALTHCHECK invokes) exits 0 on a ready
# instance. A Dockerfile/runtime regression that ships an unbootable image
# now fails CI here instead of surfacing at deploy time.
- name: Smoke test — boot the image and probe readiness
run: |
set -euo pipefail
PGPW="$(openssl rand -hex 16)"
CONN="Host=ci-db;Port=5432;Database=cluckwork;Username=cluckwork;Password=$PGPW"
# Ephemeral RSA keypair so the app can import its JWT signing keys at
# boot — generated at runtime (never a committed key). It only needs to
# be a valid keypair; the smoke test never mints or verifies a token.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-priv.pem 2>/dev/null
openssl rsa -in jwt-priv.pem -pubout -out jwt-pub.pem 2>/dev/null
JWT_PRIV="$(cat jwt-priv.pem)"
JWT_PUB="$(cat jwt-pub.pem)"
docker network create cluckwork-ci
docker run -d --name ci-db --network cluckwork-ci \
-e POSTGRES_USER=cluckwork -e POSTGRES_PASSWORD="$PGPW" -e POSTGRES_DB=cluckwork \
postgres:18.4-trixie@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a
# Wait for Postgres to accept connections before migrating. Probe over
# TCP (`-h 127.0.0.1`), NOT the default unix socket: the postgres image
# runs a socket-only temporary server during initdb, and a socket-based
# pg_isready can go green against THAT while cross-container TCP (what the
# migrate container below uses) isn't listening yet — a classic race. The
# real server binds all interfaces at once, so a TCP probe only passes
# once migrate's path is actually up.
for i in $(seq 1 30); do
if docker exec ci-db pg_isready -h 127.0.0.1 -U cluckwork -d cluckwork >/dev/null 2>&1; then break; fi
if [ "$i" = 30 ]; then echo "Postgres never became ready"; exit 1; fi
sleep 2
done
# Apply the schema with the run-then-exit migrate verb (the pre-deploy job).
docker run --rm --network cluckwork-ci \
-e Database__Provider=Postgres \
-e "ConnectionStrings__Default=$CONN" \
-e Database__AllowInsecureConnection=true \
-e "Jwt__PrivateKeyPem=$JWT_PRIV" -e "Jwt__PublicKeyPem=$JWT_PUB" \
-e Jwt__Issuer=cluckwork-ci -e Jwt__Audience=cluckwork-api-ci \
cluckwork-api:ci migrate
# Boot the serving container. Production env (ASPNETCORE_ENVIRONMENT
# unset) trips the Production serving guards, so opt out of the TLS
# floor (#262 — plaintext PG on the private network) and the
# trusted-proxy requirement (#260 — no fronting proxy here), exactly
# as the compose stack does — and (#319) supply a concrete,
# synthetic, CI-only AllowedHosts (never a real deploy hostname; the
# repo stays host-agnostic). Loopback still works: edge-security
# config force-adds localhost/127.0.0.1/[::1] to host filtering.
# Boot with a FAST health cadence (override the image's 30s timing) so
# Docker evaluates the image's own HEALTHCHECK within the job — see the
# health-status assertion below.
docker run -d --name ci-app --network cluckwork-ci -p 127.0.0.1:8080:8080 \
--health-start-period=3s --health-interval=3s --health-timeout=5s --health-retries=3 \
-e ASPNETCORE_URLS=http://+:8080 \
-e AllowedHosts=cluckwork-ci.local \
-e Database__Provider=Postgres \
-e "ConnectionStrings__Default=$CONN" \
-e Database__AllowInsecureConnection=true \
-e RateLimiting__AllowNoTrustedProxies=true \
-e "Jwt__PrivateKeyPem=$JWT_PRIV" -e "Jwt__PublicKeyPem=$JWT_PUB" \
-e Jwt__Issuer=cluckwork-ci -e Jwt__Audience=cluckwork-api-ci \
cluckwork-api:ci
# Poll /health/ready until it returns exactly HTTP 200 (DB reachable +
# migrations applied). Assert the status code explicitly — `curl -f`
# alone would also accept a 3xx.
ready=""
for i in $(seq 1 40); do
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/health/ready || true)"
if [ "$code" = "200" ]; then ready=1; break; fi
sleep 3
done
if [ -z "$ready" ]; then echo "app /health/ready never returned 200 (last code: ${code:-none})"; exit 1; fi
# Liveness must be a plain 200 too.
[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/health/live)" = "200" ] \
|| { echo "/health/live did not return 200"; exit 1; }
# Exercise Docker's OWN HEALTHCHECK directive (not just the verb): wait
# for the container to report `healthy`. A deleted/misspelled HEALTHCHECK
# line in the Dockerfile surfaces HERE — `.State.Health` is then nil and
# the inspect prints "none", which we fail on.
healthy=""
for i in $(seq 1 20); do
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' ci-app 2>/dev/null || echo none)"
if [ "$status" = "healthy" ]; then healthy=1; break; fi
if [ "$status" = "none" ]; then echo "image declares no HEALTHCHECK"; exit 1; fi
sleep 3
done
if [ -z "$healthy" ]; then echo "container never reported healthy (last: ${status:-none})"; exit 1; fi
# And assert the verb's exit-code contract directly, as the non-root user.
docker exec ci-app dotnet Cluckwork.Api.dll healthcheck
- name: Dump app logs on smoke-test failure
if: failure()
run: docker logs ci-app 2>&1 | tail -100 || true
- name: Tear down smoke-test containers
if: always()
run: |
docker rm -f ci-app ci-db >/dev/null 2>&1 || true
docker network rm cluckwork-ci >/dev/null 2>&1 || true
# #351 — hand the VERIFIED image to the publish job. Jobs run on separate
# runners with separate Docker daemons, and `needs:` passes only strings,
# so the image built above dies with this runner unless carried across.
#
# Deliberately NOT a rebuild in the publish job: a second `docker build`
# produces different bytes (fresh restore timestamps, layer metadata) and
# therefore a different digest, so what got published would be an image
# this job's Trivy scan and boot smoke test never examined.
#
# Only on a merge into main, so PR runs pay none of this.
- name: Export the verified image for publishing
id: export
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|| github.event_name == 'workflow_dispatch'
run: |
set -euo pipefail
docker save cluckwork-api:ci | gzip > image.tar.gz
printf 'image_id=%s\n' \
"$(docker image inspect -f '{{.Id}}' cluckwork-api:ci)" >> "$GITHUB_OUTPUT"
- name: Upload the verified image
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|| github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
name: runtime-image
path: image.tar.gz
# Already gzipped — re-zipping it would burn a minute for nothing.
compression-level: 0
# Purely an intra-run handoff; the registry is the durable copy.
retention-days: 1
# #351 — publish the image this run verified, named by COMMIT.
#
# No version is decided here, and no git tag is cut. That is the release
# workflow's job, and it happens later, behind a human merging the release PR.
# This job's only claim is "the image for commit X is these exact bytes, and
# they passed every gate" — which makes it idempotent per commit, free of
# ordering hazards, and impossible to race: two merges publish two different
# names.
#
# Gated on build-and-test, web AND image: those run in parallel, so only a job
# downstream of all three knows the run was green.
publish:
name: Publish the commit image
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [build-and-test, web, image]
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|| github.event_name == 'workflow_dispatch'
permissions:
# Job-level permissions REPLACE the workflow-level `contents: read`.
contents: read
packages: write # push the image to GHCR
id-token: write # mint the OIDC token the provenance signer needs (#354)
attestations: write # record the attestation against this repo (#354)
outputs:
image: ${{ steps.publish.outputs.image }}
digest: ${{ steps.publish.outputs.digest }}
steps:
# A dispatch names its own commit; a push is its own. Either way the
# commit must ALREADY be on main's history — the compare API answers
# `identical` or `behind` only when the head is an ancestor of the base.
# Without that, this job would publish arbitrary branch content under a
# `:sha-` name that the release workflow is willing to promote.
- name: Resolve and authorise the commit to publish
id: target
env:
GH_TOKEN: ${{ github.token }}
INPUT_SHA: ${{ inputs.sha }}
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
sha="$INPUT_SHA"
else
sha="$GITHUB_SHA"
fi
if ! printf '%s' "$sha" | grep -qE '^[0-9a-f]{40}$'; then
echo "::error::'${sha:-(empty)}' is not a full commit sha"
exit 1
fi
status="$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" --jq '.status')"
case "$status" in
identical|behind) ;;
*)
echo "::error::$sha is not an ancestor of main (compare status: $status) — refusing to publish it"
exit 1 ;;
esac
printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
- name: Download the verified image
uses: actions/download-artifact@v8
with:
name: runtime-image
# Assert the loaded bytes are the SCANNED bytes, by local image Id.
# Artifact substitution is not reachable today (artifacts are run-scoped,
# only one step uploads that name, `overwrite` is unset) — but the whole
# promise here is "what shipped is what CI gated", and a promise resting on
# an argument rather than a check is the one that quietly stops being true.
- name: Load and verify the image
env:
EXPECTED_ID: ${{ needs.image.outputs.image_id }}
run: |
set -euo pipefail
gunzip -c image.tar.gz | docker load
loaded="$(docker image inspect -f '{{.Id}}' cluckwork-api:ci)"
if [ -z "$EXPECTED_ID" ] || [ "$loaded" != "$EXPECTED_ID" ]; then
echo "::error::loaded image ${loaded} is not the scanned image ${EXPECTED_ID:-(unset)}"
exit 1
fi
# `x-access-token` rather than `${{ github.actor }}`: GHCR authenticates
# the token, not the username, so interpolating run-triggered metadata into
# the step handling the registry credential buys nothing.
- name: Log in to GHCR
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: echo "$GHCR_TOKEN" | docker login ghcr.io -u x-access-token --password-stdin
- name: Publish the commit image
id: publish
env:
SHA: ${{ steps.target.outputs.sha }}
run: |
set -euo pipefail
# GHCR rejects uppercase; the owner/repo casing is not ours to assume.
image="ghcr.io/${GITHUB_REPOSITORY,,}"
# Named by commit, so this can only ever be rewritten by a re-run of
# this same commit — with bytes that passed the same gates. There is
# no version tag to collide over and nothing to overwrite.
docker tag cluckwork-api:ci "$image:sha-$SHA"
docker push "$image:sha-$SHA"
# The MANIFEST digest — what `image@sha256:...` resolves to — exists
# only once the image is in a registry; before the push RepoDigests is
# empty. (The local image Id is a different hash: the config blob.)
#
# `|| true` inside the substitution: a non-matching grep exits 1, and
# under `pipefail` that status would kill the script at the assignment,
# before the -z branch below could report anything useful.
# Filter to THIS repository's entry rather than taking the first line:
# RepoDigests holds one entry per repository the image has been pushed
# to, so a bare `head -1` would be picking arbitrarily if that ever
# became more than one.
digest="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image:sha-$SHA" \
| grep -F "$image@" | grep -oE 'sha256:[0-9a-f]{64}' | head -1 || true)"
if [ -z "$digest" ]; then
echo "::error::no manifest digest after push — refusing to report an unpinnable image"
exit 1
fi
printf 'image=%s\n' "$image" >> "$GITHUB_OUTPUT"
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
# Also recorded as an artifact of THIS run, which is what the release
# workflow promotes from. A registry tag is mutable by anyone holding
# `packages: write`; an artifact is bound to the run that produced it,
# so promotion cannot be fed a digest this job did not publish.
printf '%s\n' "$digest" > published-digest.txt
{
echo "### Published \`sha-$SHA\`"
echo
echo "Not yet a release — merge the release PR to promote a version."
echo
echo '```'
echo "$image@$digest"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Cryptographically bind the pushed digest to THIS workflow run (#354).
#
# Everything else in this pipeline establishes "which digest CI published"
# by evidence the deploy side has to trust us about: a run artifact, a
# release note. None of it is verifiable by a machine that did not watch
# the run. Anyone holding `packages: write` — realistically a compromised
# collaborator PAT, not a malicious one — can push arbitrary bytes to this
# registry, and nothing downstream could tell those apart from ours.
#
# A provenance attestation is checkable without trusting us:
# gh attestation verify oci://ghcr.io/<owner>/<repo>@sha256:… \
# --repo <owner>/<repo> \
# --signer-workflow <owner>/<repo>/.github/workflows/ci.yml \
# --source-ref refs/heads/main \
# --bundle-from-oci
# succeeds only for bytes this workflow signed a provenance claim over.
# Say it that precisely: an attestation is a CLAIM BY AN ACTOR, so it
# proves the named workflow attested this digest — not, independently,
# that it built it. The strength rests on that actor, which is why
# `--signer-workflow` is part of the documented command and not optional.
#
# Repair caveat: on a `workflow_dispatch` this job builds `inputs.sha`,
# but GITHUB_SHA is the tip of the dispatched REF, and the provenance
# predicate is built from the OIDC token's ref/sha. So a repaired image's
# attestation names the branch tip as its source commit, not the commit
# actually built. The digest binding is still correct — which is what
# promotion and deploy check — but do not read the provenance's source
# commit as authoritative for a dispatched rebuild. `image.json.commit` is
# the commit PROMOTION SELECTED. Usually that is the release's own
# `target_commitish`; when that records a branch name instead of a sha,
# promotion falls back to a supplied one — release-please's own output on
# a normal push, an operator's input only on a manual release repair, and
# release-please.yml says outright it cannot be cross-checked either way.
# Do not read it as "the commit the release was cut from" without that
# distinction.
#
# This is also why the verify command pins `--source-ref refs/heads/main`:
# `--signer-workflow` pins only the workflow's PATH, so without the ref a
# ci.yml edited on a branch and dispatched from there would still produce
# a promotable attestation. A repair dispatch must therefore run FROM
# main; the sha input only chooses which commit gets built.
#
# All three flags are load-bearing and none is the default:
# --bundle-from-oci without it `gh` fetches the bundle from the GITHUB
# API, not the registry — so the registry copy this
# step pushes would go unused.
# --signer-workflow without it the identity is bound to the REPO, so
# any workflow here holding `attestations: write`
# satisfies the check. Since the threat model is a
# leaked token, pin the workflow, not just the repo.
# --source-ref without it the identity is not bound to a REF, and
# `--signer-workflow` pins only the path — see the
# branch-dispatch note below.
#
# `push-to-registry: true` stores the attestation as an OCI referrer
# beside the image, which is what makes `--bundle-from-oci` possible at
# all. Note the consumer still authenticates to the REGISTRY for an
# `oci://` subject — the saving is that it needs no GitHub API access to
# this repo, not that it needs no credentials.
#
# Placed BEFORE the digest artifact deliberately, so a failed attestation
# in THIS run leaves no artifact behind. Be precise about how far that
# goes: it is a within-a-run property only. Promotion looks the artifact
# up by NAME, repo-wide, and takes the first unexpired match, so an
# artifact from an earlier run of the same commit would still satisfy it.
# Release-level fail-closed comes from `release-please.yml` VERIFYING the
# attestation before it retags — not from this ordering. Both are wanted;
# do not let the ordering stand in for the check.
#
# And be clear what that is fail-closed AGAINST. Short version: a
# credential that can push to GHCR cannot mint a valid attestation, so
# this closes that. Promotion's own check is weaker than the deploy
# side's, because promotion runs inside a workflow a branch writer can
# edit. And NEITHER survives a change merged to `main`.
#
# AGENTS.md's "Deploy by digest" bullet is the canonical statement of that
# boundary — go there rather than re-deriving it here. This claim has been
# corrected several times and every copy of it drifted; there is one copy
# on purpose.
#
# Also why this is not `continue-on-error`: an attestation nobody checks
# is worse than none, because it looks like coverage.
#
# This action is `actions/*`, so a major-version tag is fine under the
# repo's pinning rule (third-party actions still need a commit SHA).
- name: Attest the published image's build provenance
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ steps.publish.outputs.image }}
subject-digest: ${{ steps.publish.outputs.digest }}
push-to-registry: true
# The release workflow reads this to learn which digest CI actually
# published for this commit, instead of trusting whatever the registry tag
# resolves to at promotion time.
# Named by COMMIT, not just "published-digest". The release workflow finds
# this through the repo-wide artifacts API (`?name=…`), which needs no
# knowledge of the run — and a run dispatched to repair a commit reports
# `head_sha` as the branch tip, not the commit it built, so a lookup keyed
# on the run would miss exactly the case the repair path exists for.
- name: Record the published digest
uses: actions/upload-artifact@v7
with:
name: published-digest-${{ steps.target.outputs.sha }}
path: published-digest.txt
retention-days: 90
# Drop the registry credential rather than leaving it readable in
# ~/.docker/config.json for whatever runs next.
- name: Log out of GHCR
if: always()
run: docker logout ghcr.io >/dev/null 2>&1 || true