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. | ---