chore(schema): generate PostgreSQL schema documentation #1082
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |