Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ self-hosted-runner:
# actionlint v1.7.12's bundled runner-label list predates the ubuntu-26.04
# GA (2026-09-17, actions/runner-images#14747); these are GitHub-hosted
# labels, not self-hosted runners, but this is the field actionlint uses to
# extend its known-label set. ubuntu-26.04-arm has no caller in this repo
# yet, but keep it: #996 is expected to add one, and removing it before a
# released actionlint knows the label natively would just reopen the same
# unknown-label error it lands to fix. Drop this whole file once that
# extend its known-label set. The arm64 image leg in ci.yml now uses
# ubuntu-26.04-arm. Keep both entries until a released actionlint knows
# these labels natively. Drop this whole file once that
# happens (rhysd/actionlint#682).
labels:
- ubuntu-26.04
Expand Down
151 changes: 98 additions & 53 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,8 @@ jobs:
# 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
name: Image build + Trivy scan (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
needs: [changes]
# #782 — skipped only when the pull request changed nothing but documentation.
Expand All @@ -477,10 +477,18 @@ jobs:
# main would skip `publish` with it. Do not "simplify" either half; together
# they are the difference between fail-closed and fail-open.
if: ${{ !cancelled() && needs.changes.outputs.docs_only != 'true' }}
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 }}
strategy:
fail-fast: true
# Both native image legs must use the same pinned runner OS. A floating
# label on either leg could change one build host without the other.
matrix:
include:
- arch: amd64
runner: ubuntu-26.04
cache_prefix: image-layers-
- arch: arm64
runner: ubuntu-26.04-arm
cache_prefix: arm64-image-layers-
steps:
- name: Check out repository
uses: actions/checkout@v7
Expand All @@ -497,15 +505,17 @@ jobs:
# 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.
# anything. The amd64 prefix matches e2e-smoke.yml byte for byte. The
# arm64 prefix must not start with `image-layers-`: that is amd64's broad
# fallback key, and prefix matching would restore arm64 layers there.
- 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', 'Directory.Packages.props', 'Cluckwork.sln', '.dockerignore') }}-${{ github.sha }}
key: ${{ matrix.cache_prefix }}${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Directory.Packages.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', 'Directory.Packages.props', 'Cluckwork.sln', '.dockerignore') }}-
image-layers-
${{ matrix.cache_prefix }}${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Directory.Packages.props', 'Cluckwork.sln', '.dockerignore') }}-
${{ matrix.cache_prefix }}

# 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
Expand All @@ -529,8 +539,10 @@ jobs:
# exact-pinned PackageVersion 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.
# copy of the committed tree — the real build above is untouched. The
# lock-file verdict is identical on both architectures, so run it once.
- name: Stale lock fails the Docker restore (--locked-mode drift guard)
if: matrix.arch == 'amd64'
run: |
set -euo pipefail
work="$(mktemp -d)"
Expand Down Expand Up @@ -706,24 +718,25 @@ jobs:
#
# 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"
docker tag cluckwork-api:ci "cluckwork-api:ci-${{ matrix.arch }}"
docker save "cluckwork-api:ci-${{ matrix.arch }}" | gzip > "image-${{ matrix.arch }}.tar.gz"
docker image inspect -f '{{.Id}}' cluckwork-api:ci > "image-${{ matrix.arch }}.id"

- 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
name: runtime-image-${{ matrix.arch }}
path: |
image-${{ matrix.arch }}.tar.gz
image-${{ matrix.arch }}.id
# 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.
Expand All @@ -744,10 +757,14 @@ jobs:
# and per #351 a job that should gate a release belongs in this `needs`, because
# this list is exactly what the digest artifact proves. Naming the matrix job
# once covers all of its legs: `needs` is satisfied only when every leg
# succeeded, and a leg cancelled by `fail-fast` is not a success.
# succeeded, and a leg cancelled by `fail-fast` is not a success. The same
# dependency on `image` waits for both native architecture legs (#995).
publish:
name: Publish the commit image
runs-on: ubuntu-latest
# Pin the publisher's OS: the push-output digest parser and dry-run index
# hashing depend on its Docker and buildx versions. A floating label can
# change both without a workflow edit.
runs-on: ubuntu-26.04
timeout-minutes: 15
needs: [tests, build-and-test, web, image]
if: |
Expand Down Expand Up @@ -796,27 +813,30 @@ jobs:

printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"

- name: Download the verified image
- name: Download the verified images
uses: actions/download-artifact@v8
with:
name: runtime-image
pattern: runtime-image-*
merge-multiple: true

# 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 }}
- name: Load and verify the images
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
for arch in amd64 arm64; do
expected="$(cat "image-$arch.id")"
gunzip -c "image-$arch.tar.gz" | docker load
loaded="$(docker image inspect -f '{{.Id}}' "cluckwork-api:ci-$arch")"
platform="$(docker image inspect -f '{{.Architecture}}' "cluckwork-api:ci-$arch")"
if [ -z "$expected" ] || [ "$loaded" != "$expected" ] || [ "$platform" != "$arch" ]; then
echo "::error::loaded $arch image does not match its scanned image Id or architecture"
exit 1
fi
done

# `x-access-token` rather than `${{ github.actor }}`: GHCR authenticates
# the token, not the username, so interpolating run-triggered metadata into
Expand All @@ -835,29 +855,54 @@ jobs:
# 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
# Keep digest-keyed architecture tags so a repair build of this commit
# cannot orphan an older release's child manifests. The plain arch
# tags are convenient, but move when that same commit is republished.
# Build the index from digests: a registry writer can move either tag
# after its push, but cannot change the manifest this run pushed.
declare -A child_digest=()
for arch in amd64 arm64; do
arch_tag="sha-$SHA-$arch"
docker tag "cluckwork-api:ci-$arch" "$image:$arch_tag"
push_log="$(docker push "$image:$arch_tag")"
printf '%s\n' "$push_log"
# Docker reports the pushed manifest digest. RepoDigests can still
# name a source index when a single platform was pushed from it.
child_digest[$arch]="$(printf '%s\n' "$push_log" |
sed -nE "s/^$arch_tag: digest: (sha256:[0-9a-f]{64}) size: [0-9]+$/\1/p")"
if ! [[ ${child_digest[$arch]} =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::no $arch manifest digest after push"
exit 1
fi
durable_tag="$image:sha-$SHA-$arch-${child_digest[$arch]#sha256:}"
docker buildx imagetools create --prefer-index=false \
--tag "$durable_tag" "$image@${child_digest[$arch]}"
done

# --dry-run prints the exact index JSON with one trailing newline.
# Command substitution removes that newline, leaving the bytes buildx
# pushes. Compute the digest before publishing; never resolve a tag
# to decide what to attest.
index_json="$(docker buildx imagetools create --dry-run \
"$image@${child_digest[amd64]}" "$image@${child_digest[arm64]}")"
digest="sha256:$(printf '%s' "$index_json" | sha256sum | cut -d ' ' -f1)"
docker buildx imagetools create --tag "$image:sha-$SHA" \
"$image@${child_digest[amd64]}" "$image@${child_digest[arm64]}"

# Fetch by the locally computed digest. One immutable read supplies
# every assertion; extra platforms or substituted children fail.
index_json="$(docker buildx imagetools inspect "$image@$digest" --raw)"
printf '%s' "$index_json" | jq -e \
--arg amd64 "${child_digest[amd64]}" --arg arm64 "${child_digest[arm64]}" '
(.mediaType | endswith("image.index.v1+json") or endswith("manifest.list.v2+json")) and
([.manifests[] | .platform.os + "/" + .platform.architecture] |
sort == ["linux/amd64", "linux/arm64"]) and
([.manifests[] | {architecture: .platform.architecture, digest: .digest}] |
sort_by(.architecture) == [
{architecture: "amd64", digest: $amd64},
{architecture: "arm64", digest: $arm64}
])
' >/dev/null

printf 'image=%s\n' "$image" >> "$GITHUB_OUTPUT"
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
Expand Down
12 changes: 4 additions & 8 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -418,14 +418,10 @@ jobs:
# Server-side retag: adds a name to an EXISTING manifest. Nothing is
# pulled and nothing is rebuilt.
#
# `--prefer-index=false` is load-bearing, not a tidy-up. It defaults to
# TRUE, and with a single source that is not already a list the default
# wraps the manifest in a NEW image index — which has a different
# top-level digest. The whole point here is that the version tag
# resolves to the digest CI scanned, so the carbon copy is the only
# correct behaviour. (`docker buildx imagetools create --help`:
# "prefer outputting an image index or manifest list instead of
# performing a carbon copy (default true)".)
# CI now supplies an index (#995). A local registry test found that
# both settings copy an index source with the same digest. Keep
# `--prefer-index=false` for older single-manifest releases: the
# default wraps one of those in a new index and changes its digest.
docker buildx imagetools create --prefer-index=false \
--tag "$image:$TAG" "$image@$digest"

Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,15 @@ CI rejects a PR when a **production** dependency has a known **high+** advisory:
Two stages stay separate: **CI publishes an image per merge; the release PR turns one into a version.** [`docs/releasing.md`](docs/releasing.md) is the **how-to**, this section defines the **invariants**, and [`351-releases.md`](docs/decisions/351-releases.md) explains the mechanism.

- **Every merge to `main`** publishes `ghcr.io/<owner>/<repo>:sha-<commit>` from the `publish` job. **Merging the "Release vX.Y.Z" PR** drafts the release, **promotes** that commit's image to `:vX.Y.Z`, then publishes.
- **Promotion is a server-side retag of the existing digest** (`--prefer-index=false` is load-bearing; the default wraps a new top-level digest), **never a rebuild** — a rebuild yields different bytes no scan ever examined.
- **Promotion is a server-side retag of the existing digest**, **never a rebuild** — a rebuild yields different bytes no scan ever examined. Keep `--prefer-index=false` for older single-manifest releases, which the default would wrap in a new index. For a CI-published index, both settings preserve its digest; the post-retag digest check remains mandatory.
- **Publish a two-platform index from verified native builds (#995).** The `image` matrix builds, Trivy-scans and boots amd64 and arm64 on their own runners, then hands each image and its local Id to `publish` in a separate artifact. `publish` checks each loaded Id and architecture, captures each pushed manifest digest, builds the index from those digest references and checks both children. It computes the index digest locally and verifies the published bytes by that digest, never by a mutable tag. The digest-keyed `:sha-<commit>-<arch>-<manifest-hex>` tags keep released children reachable through later repair builds; plain arch tags are convenience only. The amd64 cache key stays shared with `e2e-smoke.yml`; arm64's prefix must not begin with amd64's broad `image-layers-` restore key. The #315 lock-drift guard runs once because its verdict does not depend on architecture. → [`995-multi-arch-images.md`](docs/decisions/995-multi-arch-images.md)
- **Promotion reads the digest from CI's own run artifact**, never by resolving `:sha-<commit>`, which is mutable between merge and CI's push. **Add every release-gating CI job to `publish.needs`;** that list defines what the digest artifact proves.
- **The release stays a draft until its image is promoted**; GitHub withholds the git tag for a draft, so a failed promotion leaves no version pointing at nothing.
- **The version comes from conventional commits, damped below 1.0.0.** A *breaking* commit bumps the **minor**, and breaking means **either** form — a `!` after the type (`feat!:`, `fix!:`) **or** a `BREAKING CHANGE` footer. Everything else, `feat:` included, is a **patch**. The damping is `bump-minor-pre-major` + `bump-patch-for-minor-pre-major` in `release-please-config.json`, so the mapping flips **silently at 1.0.0** — reach it deliberately with a `Release-As:` footer.
- **A commit-body parse error drops the whole commit** (no changelog entry, no bump, green run): never start a line with `word(` that has another `(` inside it. `.githooks/commit-msg` catches the body, but no local hook sees a **PR title** — and on a multi-commit PR the title *is* the release note.
- **The release PR is opened with a GitHub App token, not `GITHUB_TOKEN`.** Every App consumer **must** keep `permission-*` downscoping — omitting it mints the union of every grant the App holds, silently.
- **Never hand-edit `.release-please-manifest.json` or `version.txt`** — release-please owns them.
- **Deploy by digest, never by tag.** *Obtaining* the digest and *verifying* its origin are two separate problems: get it from the release's `image.json` asset, verify with `gh attestation verify` (all three of `--bundle-from-oci`, `--signer-workflow`, `--source-ref` are load-bearing and none is the default), then confirm the tag still resolves to the digest you verified — comparing against `reference`, **never** the asset's separate `digest` field. Full commands: [`docs/releasing.md`](docs/releasing.md#deploying).
- **Deploy by the index digest, never by tag or a platform manifest digest.** *Obtaining* the digest and *verifying* its origin are two separate problems: get the index reference from the release's `image.json` asset, verify that reference with `gh attestation verify` (all three of `--bundle-from-oci`, `--signer-workflow`, `--source-ref` are load-bearing and none is the default), then confirm the version tag still resolves to the index digest you verified — comparing against `reference`, **never** the asset's separate `digest` field. The attestation names the index; Docker resolves its amd64 or arm64 child at pull time. Full commands: [`docs/releasing.md`](docs/releasing.md#deploying).

Net, stated at exactly the strength the argument supports: the internal gate
fails closed for a leaked **registry** credential. The external gate also
Expand Down
Loading
Loading