From ade9547b20b2b92e20818b671b97b083bb8d8bbc Mon Sep 17 00:00:00 2001 From: Jose <75870284+Jaro-c@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:56:53 -0500 Subject: [PATCH] ci(release-verify): per-asset signature verification, plus SHA256SUMS gate Two real security improvements, in the reusables that gate every release. 1. installer-contract.yml: assert the release workflow publishes a SHA256SUMS manifest. The install scripts verify downloaded binaries against this file; a release that stops producing it is a silent breaking change (install.sh fetches the URL and gets a 404 mid-installer, the failure mode that motivated the gate). The check matches the presence of the path on the manifest side, not the upload step name on the workflow side, so it stays valid across workflow renames. 2. release-verify.yml: per-asset signature verification. The signature covers each binary independently, so a manifest re-signed to legitimize a swapped binary still fails the check (a manifest-only check would pass that case). Iterates every per-asset .sig (per-binary, per-deb, per-SBOM, per-installer), not just SHA256SUMS.sig, using the org Ed25519 release key as input. Skipped for manifest-kind: go because Go modules use the tag itself, not per-binary signatures. Closes the per-binary substitution gap that #1359 closed in podup's own release workflow. The org reusable should not require every consumer to reimplement it. Signed-off-by: Jose <75870284+Jaro-c@users.noreply.github.com> --- .github/workflows/installer-contract.yml | 20 ++++++ .github/workflows/release-verify.yml | 88 ++++++++++++++++++++++++ docs/reusables/README.md | 2 +- docs/reusables/installer-contract.md | 2 +- docs/reusables/release-verify.md | 1 + 5 files changed, 111 insertions(+), 2 deletions(-) diff --git a/.github/workflows/installer-contract.yml b/.github/workflows/installer-contract.yml index f0707fa..fca9b3b 100644 --- a/.github/workflows/installer-contract.yml +++ b/.github/workflows/installer-contract.yml @@ -5,6 +5,11 @@ name: Installer contract (reusable) # install.sh downloads `-${OS}-${ARCH}` for the platform it runs on, and # the release workflow publishes a fixed list of assets. If the two drift, the # installer breaks for users. This catches the drift on the PR, before any tag. +# +# Also asserts the release workflow produces a `SHA256SUMS` manifest — the +# same manifest the install scripts verify downloaded binaries against. A +# release without one cannot be verified at install time, and a release +# workflow that stops producing one is a silent breaking change. on: workflow_call: @@ -76,6 +81,21 @@ jobs: if not published: sys.exit(f"installer-contract: could not parse asset names from {release_yml}.") + # The release must publish a SHA256SUMS manifest — the install + # scripts verify downloaded binaries against this file. A release + # workflow that stops producing one is a silent breaking change + # (install.sh keeps fetching the URL and gets a 404 mid-installer, + # which is the failure mode that motivated the gate). Match the + # presence of the path on the manifest side, not the upload step + # name on the workflow side, so the check stays valid across + # workflow renames. + if "SHA256SUMS" not in published: + sys.exit( + "installer-contract: release workflow does not publish " + "a SHA256SUMS asset; install scripts cannot verify downloads.\n" + f"published: {sorted(published)}" + ) + missing = sorted(requestable - published) if missing: lines = "\n".join(f" - {n}" for n in missing) diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index dacb567..e3c0c37 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -94,6 +94,10 @@ on: description: Dependency-audit command to run after the version gates (e.g. "cargo audit --locked"); skipped when empty type: string default: "" + release-pubkey-b64: + description: Base64 (no padding) Ed25519 public key that signs per-asset `.sig` files. Default is the current org release key; override on rotation. + type: string + default: "HFv7vg5FCY7YyKUDbJhaQSfB9SboJGSblJtFbLmLHzM" outputs: verified-sha: description: git rev-parse HEAD of the checked-out tag, captured right after checkout. Tags are mutable and can move after this job resolves them, so a job that builds/publishes after verification should check out this SHA (or assert it equals its own HEAD) instead of re-resolving the tag. @@ -262,6 +266,90 @@ jobs: fi echo "OK: ${sha} is reachable from origin/main." + - name: Per-asset signature verification + # Closes the per-binary substitution gap where the manifest could + # be re-signed to match a swapped binary. Iterates every per-asset + # .sig (per-binary, per-deb, per-SBOM, per-installer), not just + # SHA256SUMS.sig — a manifest-only check would pass a release + # where the manifest was re-signed to legitimize a swapped binary, + # because the manifest's hash of the binary still matches. Per-asset + # .sig covers each binary independently, so a swap fails the check + # even if the manifest is intact. + # + # Skipped for `manifest-kind: go` because Go modules are versioned + # by the tag itself and the per-binary .sig convention is not used + # by the Go release workflows in the org. + env: + TAG: ${{ inputs.tag }} + RELEASE_PUBKEY_B64: ${{ inputs.release-pubkey-b64 }} + if: inputs.manifest-kind != 'go' + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/assets" + cd "$RUNNER_TEMP/assets" + + # List assets. The API call is the documented release-listing path; + # we do not use `gh release download` because we want to iterate + # asset names, not bulk-download. `gh` is the org's preinstalled + # binary, not a third-party action, and this is read-only. + assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')" + if [ -z "$assets" ]; then + echo "::error::No assets found for tag ${TAG}; cannot verify per-asset signatures." + exit 1 + fi + + # Find every .sig and its binary. A .sig is named for one binary; + # we download both and verify the sig against the binary. + sigs="$(printf '%s\n' "$assets" | grep '\.sig$' || true)" + if [ -z "$sigs" ]; then + echo "::error::No .sig files in the release assets; per-asset signature verification requires one per binary." + exit 1 + fi + + bad=0 + verified=0 + for sig in $sigs; do + binary="${sig%.sig}" + if ! printf '%s\n' "$assets" | grep -qx "$binary"; then + echo "::error file=$sig::Signature $sig has no matching binary in the release." + bad=1 + continue + fi + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern "$binary" --pattern "$sig" --dir . --clobber >/dev/null + if [ ! -s "$binary" ] || [ ! -s "$sig" ]; then + echo "::error::Failed to download $binary / $sig for verification." + bad=1 + continue + fi + if ! python3 - "$sig" "$binary" "$RELEASE_PUBKEY_B64" <<'PY' + import base64, sys + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + sig_path, data_path, pubkey_b64 = sys.argv[1], sys.argv[2], sys.argv[3] + try: + pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64 + "==")) + except Exception as e: + print(f"FAIL {sig_path}: invalid release pubkey ({e})") + sys.exit(1) + try: + pub.verify(open(sig_path, "rb").read(), open(data_path, "rb").read()) + print(f"OK {sig_path}") + except Exception: + print(f"FAIL {sig_path}: signature does not verify {data_path}") + sys.exit(1) + PY + then + verified=$((verified + 1)) + else + bad=1 + fi + done + echo "Verified $verified signature(s)." + if [ "$bad" -ne 0 ]; then + echo "::error::One or more per-asset signatures failed verification; the release cannot be trusted." + exit 1 + fi + - name: Run dependency audit if: inputs.audit-command != '' env: diff --git a/docs/reusables/README.md b/docs/reusables/README.md index 573bb70..703b483 100644 --- a/docs/reusables/README.md +++ b/docs/reusables/README.md @@ -18,7 +18,7 @@ which blocks every pull request until someone works out why. | [`main-guard`](main-guard.md) | 1 | 0 | 0 | | [`powershell-ci`](powershell-ci.md) | 1 | 0 | 3 | | [`python-ci`](python-ci.md) | 1 | 0 | 5 | -| [`release-verify`](release-verify.md) | 1 | 0 | 5 | +| [`release-verify`](release-verify.md) | 1 | 0 | 6 | | [`rust-audit`](rust-audit.md) | 2 | 0 | 4 | | [`rust-ci`](rust-ci.md) | 8 | 6 | 12 | | [`rust-debian`](rust-debian.md) | 2 | 1 | 8 | diff --git a/docs/reusables/installer-contract.md b/docs/reusables/installer-contract.md index 82d2926..7ea3bf0 100644 --- a/docs/reusables/installer-contract.md +++ b/docs/reusables/installer-contract.md @@ -1,6 +1,6 @@ # installer-contract -Assert install.sh and the release workflow agree on release asset names. Releases are immutable: once a tag ships, its asset names can never change. install.sh downloads `-${OS}-${ARCH}` for the platform it runs on, and the release workflow publishes a fixed list of assets. If the two drift, the installer breaks for users. This catches the drift on the PR, before any tag. +Assert install.sh and the release workflow agree on release asset names. Releases are immutable: once a tag ships, its asset names can never change. install.sh downloads `-${OS}-${ARCH}` for the platform it runs on, and the release workflow publishes a fixed list of assets. If the two drift, the installer breaks for users. This catches the drift on the PR, before any tag. Also asserts the release workflow produces a `SHA256SUMS` manifest — the same manifest the install scripts verify downloaded binaries against. A release without one cannot be verified at install time, and a release workflow that stops producing one is a silent breaking change. ## Calling it diff --git a/docs/reusables/release-verify.md b/docs/reusables/release-verify.md index a84f255..c6a158a 100644 --- a/docs/reusables/release-verify.md +++ b/docs/reusables/release-verify.md @@ -35,6 +35,7 @@ a required check whose name nothing emits blocks every pull request. | `extra-version-files` | string | — | no | Newline-separated entries. path is everything before the first colon. What follows is the anchored form when it contains the literal delimiter "::": anchor is the text before that first "::", pattern is everything after it and may itself contain further colons (e.g. "Cargo.lock:name = \"podup\"::version = \"VERSION\"" — use this form whenever the pattern isn't already scoped to the right entry by a unique prefix, since a bare "version = " match can hit an unrelated dependency at the same version). Otherwise the whole remainder is a flat grep -E pattern, matched anywhere in the file, colons and all (e.g. "debian/changelog:^podup \(VERSION\)"; a flat pattern is free to contain a literal colon, e.g. "notes.txt:Category: VERSION" matches that literal text anywhere in the file). An anchor containing a literal colon is not supported — the first "::" found always wins. An anchor must also uniquely identify one entry: if it matches more than one line, grep -A3 prints a window per match and they are concatenated before the pattern check runs against the combined text. Escape any literal regex metacharacter in anchor or pattern (parens, dots, etc. — grep -E, not a literal-string match); the literal token VERSION is substituted into both anchor and pattern before matching. Blank lines are skipped; every non-blank entry must match its file (and anchor, when given) or the job fails naming that file. Default checks nothing extra. | | `tag` | string | — | yes | The tag being verified, e.g. the github.ref_name context value on a tag push, or the caller's own workflow_dispatch tag input for a re-run | | `audit-command` | string | — | no | Dependency-audit command to run after the version gates (e.g. "cargo audit --locked"); skipped when empty | +| `release-pubkey-b64` | string | `HFv7vg5FCY7YyKUDbJhaQSfB9SboJGSblJtFbLmLHzM` | no | Base64 (no padding) Ed25519 public key that signs per-asset `.sig` files. Default is the current org release key; override on rotation. | ---