From aba886b17a5d52cf7d54330fb5360888bce2923c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 07:24:43 +0000 Subject: [PATCH 1/3] image(uv): derive the checksum from the attested release tarball uv's committed checksum was fetched from releases.astral.sh, which serves the sum the release workflow wrote next to the tarball, so the build trusted whatever that release carried. uv attests every release archive with actions/attest-build-provenance, so the checksum can be derived the way pnpm's is: checksum.sh downloads the tarball, has `gh attestation verify` bind its digest to uv's release workflow, and prints the sha256 line that update-material.sh commits. The release workflow is dispatched on main and creates the version's tag at the commit it ran on, so the certificate carries refs/heads/main as the source ref rather than a tag. The script therefore resolves the tag to its commit with git ls-remote and passes it as --source-digest, which is what ties the tarball to the pinned version. The runner is GitHub-hosted, so --deny-self-hosted-runners applies as well. The committed files are unchanged: sha256sum prints the same line the previous source served. Deno stays on the checksum its release publishes: its CI computes the sums with shasum and uploads them beside the archives, with no attestation step, and the Sigstore log holds no entry for the pinned archives. The Deno wording is corrected to say what that checksum guarantees, since it comes from the same release as the archive. Co-Authored-By: Claude Fable 5.1 --- deno/Dockerfile | 8 ++++---- deno/README.md | 12 ++++++----- deno/build.yaml | 7 ++++--- uv/Dockerfile | 7 +++---- uv/README.md | 17 +++++++++++----- uv/build.yaml | 15 +++++++------- uv/checksum.sh | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 28 deletions(-) create mode 100755 uv/checksum.sh diff --git a/deno/Dockerfile b/deno/Dockerfile index 6e828e0..d33b2ee 100644 --- a/deno/Dockerfile +++ b/deno/Dockerfile @@ -13,10 +13,10 @@ ARG TARGETARCH USER root SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# Deno publishes no signature for its release archives, so each archive is -# verified against a checksum file committed to this repository (kept in -# sync by .github/workflows/update-material.yml and reviewed like any other -# change). +# Deno publishes neither a signature nor build provenance for its release +# archives, so each archive is verified against a checksum file committed to +# this repository (kept in sync by .github/workflows/update-material.yml and +# reviewed like any other change). RUN --mount=type=bind,source=deno-amd64.sha256,target=/tmp/deno-amd64.sha256,ro \ --mount=type=bind,source=deno-arm64.sha256,target=/tmp/deno-arm64.sha256,ro \ case "${TARGETARCH}" in \ diff --git a/deno/README.md b/deno/README.md index c0097ca..bebaa98 100644 --- a/deno/README.md +++ b/deno/README.md @@ -59,11 +59,13 @@ Completions are generated at build time with `deno completions bash` and install ## Supply chain `deno` is downloaded directly from [GitHub Releases](https://github.com/denoland/deno/releases). -Deno publishes no signature for its release archives, so each archive is verified against a -SHA-256 checksum file committed to this repository (`deno/deno-.sha256`) rather than one -fetched from the same server as the binary. The checksum files are kept in sync with the pinned -`DENO_VERSION` by an automated workflow and reviewed like any other change, so later tampering -with the download channel cannot affect builds. +Deno publishes neither a signature nor build provenance for its release archives, only a SHA-256 +checksum on the same release, so each archive is verified against a copy of that checksum +committed to this repository (`deno/deno-.sha256`) rather than one fetched at build time. +The checksum files are taken from the release when the pinned `DENO_VERSION` changes, by an +automated workflow, and reviewed like any other change, so a build accepts only the archive that +was published when the version was pinned, and later tampering with the download channel cannot +affect builds. ## Verifying the image diff --git a/deno/build.yaml b/deno/build.yaml index f0b69ab..006d4ef 100644 --- a/deno/build.yaml +++ b/deno/build.yaml @@ -8,9 +8,10 @@ materials: commit_to_pr: true description: >- Checksum for deno (amd64) from GitHub Releases, matching the pinned - DENO_VERSION. Deno publishes no signature for its release archives, so - this committed file is the trust anchor the Dockerfile verifies the - downloaded archive against. dl.deno.land is not used here: it serves the + DENO_VERSION. Deno publishes neither a signature nor build provenance + for its release archives, so this copy of the checksum published on the + same release is the trust anchor the Dockerfile verifies the downloaded + archive against. dl.deno.land is not used here: it serves the LTS channel, whose archives differ from the mainline GitHub Releases the Dockerfile downloads, so its checksums would not match. - path: deno-arm64.sha256 diff --git a/uv/Dockerfile b/uv/Dockerfile index 900b651..db348c7 100644 --- a/uv/Dockerfile +++ b/uv/Dockerfile @@ -14,10 +14,9 @@ USER root SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# uv's release tarball is verified against a checksum committed to this repo -# (downloaded from https://releases.astral.sh/, kept in sync by -# .github/workflows/update-material.yml and reviewed like any other change) -# instead of one fetched alongside the binary. +# uv's release tarball is verified against a checksum committed to this +# repository, which checksum.sh derives from the tarball's build provenance +# and .github/workflows/update-material.yml keeps in sync with UV_VERSION. RUN --mount=type=bind,source=uv-amd64.sha256,target=/tmp/uv-amd64.sha256,ro \ --mount=type=bind,source=uv-arm64.sha256,target=/tmp/uv-arm64.sha256,ro \ case "${TARGETARCH}" in \ diff --git a/uv/README.md b/uv/README.md index 2b7cafc..1199a50 100644 --- a/uv/README.md +++ b/uv/README.md @@ -84,11 +84,18 @@ setup does keep the two together. ## Supply chain -`uv` is downloaded directly from [GitHub Releases](https://github.com/astral-sh/uv/releases) -and verified before installation against a checksum committed to this repository. -The checksum is sourced from [releases.astral.sh](https://releases.astral.sh/) and -kept in sync with the pinned version by `.github/workflows/update-material.yml`, so it -is reviewed like any other change rather than fetched alongside the binary at build time. +`uv` is downloaded from [GitHub Releases](https://github.com/astral-sh/uv/releases) and verified +against a checksum committed to this repository (`uv/uv-amd64.sha256`, `uv/uv-arm64.sha256`). + +uv publishes a checksum next to each release tarball, but it comes from the same release as the +tarball, so that checksum is not what is committed — the committed one is derived here, and only +from a tarball whose origin has been established. `.github/workflows/update-material.yml` runs +`uv/checksum.sh` whenever the pinned version changes, and the script records a digest only after +`gh attestation verify` has confirmed that the tarball's +[build provenance](https://github.com/astral-sh/uv/blob/main/.github/workflows/release.yml) was +signed by uv's release workflow at the commit the version's tag points to. The resulting checksum +is committed and reviewed like any other change, so what the image build trusts is a digest this +repository accepted, rather than one the release host served alongside the tarball. Note that this covers the `uv` binary only. Interpreters and packages that uv installs at runtime are fetched from their own upstreams under uv's own verification, outside this image's diff --git a/uv/build.yaml b/uv/build.yaml index 666d292..3fc1ab2 100644 --- a/uv/build.yaml +++ b/uv/build.yaml @@ -1,17 +1,18 @@ description: "Minimal Dev Container image for Python with uv: Debian, verified upstreams, SLSA provenance" -# VERSION in each url is replaced with the build_args value named by -# version_key; commit_to_pr files are pushed onto version-bump PRs. materials: - path: uv-amd64.sha256 - url: https://releases.astral.sh/github/uv/releases/download/VERSION/uv-x86_64-unknown-linux-gnu.tar.gz.sha256 + command: ["checksum.sh", "amd64"] version_key: UV_VERSION commit_to_pr: true description: >- - Checksum for uv's amd64 release tarball from releases.astral.sh, matching - the pinned UV_VERSION. This committed file is the trust anchor the - Dockerfile verifies the downloaded binary against. + Checksum for uv's amd64 release tarball, matching the pinned UV_VERSION. + The checksum uv publishes comes from the same release as the tarball, so + checksum.sh derives this one instead, accepting the tarball only after + `gh attestation verify` has bound its digest to uv's release workflow at + the commit that version's tag points to. This committed file is the + trust anchor the Dockerfile verifies the downloaded tarball against. - path: uv-arm64.sha256 - url: https://releases.astral.sh/github/uv/releases/download/VERSION/uv-aarch64-unknown-linux-gnu.tar.gz.sha256 + command: ["checksum.sh", "arm64"] version_key: UV_VERSION commit_to_pr: true description: >- diff --git a/uv/checksum.sh b/uv/checksum.sh new file mode 100755 index 0000000..e7f4b96 --- /dev/null +++ b/uv/checksum.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# checksum.sh — print the sha256 line for a uv release tarball, after +# confirming the tarball's build provenance. +# +# uv publishes a checksum next to each release tarball, but it is written by +# the same workflow run and served from the same release, so it vouches for +# nothing the tarball does not already assert about itself. This repository +# derives its own trust anchor instead: the tarball is accepted only once +# `gh attestation verify` has bound its digest to uv's release workflow at the +# commit the version's tag points to. The digest is then committed as trust +# material and is what the image build verifies against, the way every other +# image here verifies a committed checksum. +# +# Run by scripts/update-material.sh from the materials declared in +# build.yaml, with UV_VERSION taken from the pinned build args. Needs `gh` +# authenticated for the read of a public attestation, and `git` for the tag +# lookup; GitHub-hosted runners provide all three. +# +# Usage: +# UV_VERSION= checksum.sh +set -euo pipefail + +ARCH="${1:?Usage: UV_VERSION= checksum.sh }" +: "${UV_VERSION:?UV_VERSION must be set}" + +case "$ARCH" in + amd64) UV_ARCH="x86_64" ;; + arm64) UV_ARCH="aarch64" ;; + *) echo "Unsupported architecture: ${ARCH}" >&2; exit 1 ;; +esac + +TARBALL="uv-${UV_ARCH}-unknown-linux-gnu.tar.gz" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +wget -q -T 30 -t 3 -P "$TMPDIR" \ + "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${TARBALL}" + +# uv's release workflow is dispatched on main and creates the version's tag at +# the commit it ran on, so the signing certificate names refs/heads/main as +# the source ref and only its source digest ties the tarball to the version. +# For an annotated tag ls-remote lists the peeled commit after the tag object, +# so the last line holds the commit either way. +TAG_COMMIT=$(git ls-remote https://github.com/astral-sh/uv \ + "refs/tags/${UV_VERSION}" "refs/tags/${UV_VERSION}^{}" | tail -n 1 | cut -f 1) +: "${TAG_COMMIT:?no tag ${UV_VERSION} in astral-sh/uv}" + +gh attestation verify "${TMPDIR}/${TARBALL}" \ + --repo astral-sh/uv \ + --signer-workflow "astral-sh/uv/.github/workflows/release.yml" \ + --source-digest "$TAG_COMMIT" \ + --deny-self-hosted-runners >&2 + +env --chdir="$TMPDIR" sha256sum "$TARBALL" From c9992c43618b367452494b25ab079c88952168f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 07:27:02 +0000 Subject: [PATCH 2/3] docs: require signed commits under the signing key's identity Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index bed6b7d..72cf92f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,7 @@ renovate.jsonc # Renovate config - Inline comments: one of exactly three things — a behaviour of an external system, a coupling to another file, or a constraint a plausible edit would silently break. Anything else: delete it. - Comments describe the code as it is, never how it came to be. A decision, an alternative, a past incident, or an answer to review feedback is how it came to be, and belongs in the commit message and the pull request. - Default to no comment. Re-read every comment you added or reworded before committing. +- Sign every commit with the signing key the environment configures, and commit under the identity that key belongs to. Never pass another user's name or email to `git commit`: a commit whose author does not match the signing key goes out unsigned and GitHub cannot verify it. - PR titles must follow Conventional Commits format: - Allowed types: `image`, `ci`, `chore`, `test`, `docs` - The scope is optional. Examples: From 366a1b9191c240ecf3e4bd239ee7337945bab936 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 07:36:42 +0000 Subject: [PATCH 3/3] image(uv): keep the comments to the code as it is Co-Authored-By: Claude Fable 5.1 --- deno/Dockerfile | 4 ++-- uv/Dockerfile | 2 -- uv/checksum.sh | 14 ++++++-------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/deno/Dockerfile b/deno/Dockerfile index d33b2ee..546fb6d 100644 --- a/deno/Dockerfile +++ b/deno/Dockerfile @@ -15,8 +15,8 @@ USER root SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Deno publishes neither a signature nor build provenance for its release # archives, so each archive is verified against a checksum file committed to -# this repository (kept in sync by .github/workflows/update-material.yml and -# reviewed like any other change). +# this repository and kept in sync with DENO_VERSION by +# .github/workflows/update-material.yml. RUN --mount=type=bind,source=deno-amd64.sha256,target=/tmp/deno-amd64.sha256,ro \ --mount=type=bind,source=deno-arm64.sha256,target=/tmp/deno-arm64.sha256,ro \ case "${TARGETARCH}" in \ diff --git a/uv/Dockerfile b/uv/Dockerfile index db348c7..5375ca8 100644 --- a/uv/Dockerfile +++ b/uv/Dockerfile @@ -25,11 +25,9 @@ RUN --mount=type=bind,source=uv-amd64.sha256,target=/tmp/uv-amd64.sha256,ro \ *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ esac \ && TMPDIR="$(mktemp -d)" \ - # Download uv and verify it against the committed checksum && UV_TARBALL="uv-${UV_ARCH}-unknown-linux-gnu.tar.gz" \ && wget -q -T 30 -t 3 -P "${TMPDIR}" "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_TARBALL}" \ && env --chdir="${TMPDIR}" sha256sum -c "${UV_SHA256_FILE}" \ - # Install uv && tar xzf "${TMPDIR}/${UV_TARBALL}" -C "${TMPDIR}" \ && mv "${TMPDIR}/uv-${UV_ARCH}-unknown-linux-gnu/uv" "${TMPDIR}/uv-${UV_ARCH}-unknown-linux-gnu/uvx" /usr/local/bin/ \ && rm -rf "${TMPDIR}" \ diff --git a/uv/checksum.sh b/uv/checksum.sh index e7f4b96..8dc01df 100755 --- a/uv/checksum.sh +++ b/uv/checksum.sh @@ -2,14 +2,12 @@ # checksum.sh — print the sha256 line for a uv release tarball, after # confirming the tarball's build provenance. # -# uv publishes a checksum next to each release tarball, but it is written by -# the same workflow run and served from the same release, so it vouches for -# nothing the tarball does not already assert about itself. This repository -# derives its own trust anchor instead: the tarball is accepted only once -# `gh attestation verify` has bound its digest to uv's release workflow at the -# commit the version's tag points to. The digest is then committed as trust -# material and is what the image build verifies against, the way every other -# image here verifies a committed checksum. +# The tarball is accepted only once `gh attestation verify` has bound its +# digest to uv's release workflow at the commit the version's tag points to. +# The digest is then committed as trust material and is what the image build +# verifies against, the way every other image here verifies a committed +# checksum. The checksum uv publishes beside the tarball is written by the +# same workflow run, so it is not the material. # # Run by scripts/update-material.sh from the materials declared in # build.yaml, with UV_VERSION taken from the pinned build args. Needs `gh`