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
20 changes: 20 additions & 0 deletions .github/workflows/installer-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ name: Installer contract (reusable)
# install.sh downloads `<name>-${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:
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions .github/workflows/release-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/reusables/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/reusables/installer-contract.md
Original file line number Diff line number Diff line change
@@ -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 `<name>-${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 `<name>-${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

Expand Down
1 change: 1 addition & 0 deletions docs/reusables/release-verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down
Loading