diff --git a/Makefile b/Makefile index 4952317..fe14e9a 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ .PHONY: release-check release-preflight release-clean release-download release-notes .PHONY: release-checksums release-sign release-export-keys release-verify-checksums .PHONY: release-verify-signatures release-verify-keys release-verify release-upload release +.PHONY: release-verify-tag-workflow release-verify-draft-assets release-download-signed release-smoke-consumer VERSION := $(shell tr -d '\r\n' < VERSION 2>/dev/null || echo "dev") SYSPRIMS_PTY_RELEASE_TAG ?= v$(VERSION) @@ -20,10 +21,13 @@ help: 'release-check Run package and dry-run publish gates' \ 'release-preflight Verify clean synced main and all pre-tag gates' \ 'release-download Download draft release provenance assets' \ + 'release-download-signed Download signed release assets for verification' \ 'release-checksums Copy notes and generate checksum manifests' \ 'release-sign Sign checksum manifests locally' \ 'release-export-keys Export public signing keys locally' \ 'release-verify Verify checksums, signatures, and public-only keys' \ + 'release-verify-tag-workflow Verify tag Release workflow success' \ + 'release-smoke-consumer Verify registry-only consumer after crates.io publish' \ 'release-upload Upload signed assets; does not undraft release' check: @@ -85,6 +89,15 @@ release-clean: release-download: release-clean release-guard-tag-version-post ./scripts/download-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)" +release-download-signed: release-clean release-guard-tag-version-post + ./scripts/download-signed-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)" + +release-verify-tag-workflow: release-guard-tag-version-post + ./scripts/verify-tag-release-workflow.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" + +release-verify-draft-assets: release-guard-tag-version-post + ./scripts/verify-release-draft-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$${MODE:-unsigned}" + release-notes: mkdir -p "$(DIST_RELEASE)" cp "docs/releases/$(SYSPRIMS_PTY_RELEASE_TAG).md" "$(DIST_RELEASE)/release-notes-$(SYSPRIMS_PTY_RELEASE_TAG).md" @@ -114,5 +127,8 @@ release-verify: release-verify-checksums release-verify-signatures release-verif release-upload: release-guard-tag-version-post release-verify ./scripts/upload-release-assets.sh "$(SYSPRIMS_PTY_RELEASE_TAG)" "$(DIST_RELEASE)" +release-smoke-consumer: + ./scripts/smoke-registry-consumer.sh + release: release-clean release-download release-checksums release-sign release-export-keys release-verify release-upload @echo "[ok] release assets uploaded; undraft requires a separate explicit cue" diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index ed258ee..5e42181 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -26,9 +26,28 @@ git push origin "v${VERSION}" The tag workflow must create a draft GitHub release only. It must not sign assets and must not publish to crates.io. +Before any crates.io upload, require the tag workflow to be green on the exact +tag and verify the unsigned draft release asset set: + +```bash +VERSION=$(cat VERSION) +SYSPRIMS_PTY_REQUIRE_TAG=1 make release-guard-tag-version +make release-verify-tag-workflow +make release-verify-draft-assets +``` + +Expected unsigned draft assets: + +- `LICENSE-MIT` +- `release-notes-vX.Y.Z.md` +- `sbom-X.Y.Z.cdx.json` + ## First crates.io publication Publication is irreversible and requires a separate explicit maintainer cue. +Use `CARGO_REGISTRY_TOKEN` or an equivalent Cargo credential source with the +minimum required crate scope. Do not write token material or owner identities +to tracked files, release notes, PR text, or planning files. ```bash VERSION=$(cat VERSION) @@ -48,6 +67,20 @@ tarball differs from the reviewed package. After the draft GitHub release exists and the crates.io publish cue has been handled: +Required local signing environment: + +- `SYSPRIMS_PTY_RELEASE_TAG`: release tag with the leading `v`, for example + `v0.9.0`. +- `SYSPRIMS_PTY_MINISIGN_KEY`: minisign secret key path. +- `SYSPRIMS_PTY_MINISIGN_PUB`: minisign public key path. If omitted, the + tooling derives it from `SYSPRIMS_PTY_MINISIGN_KEY` by replacing `.key` with + `.pub`. + +Optional PGP signing is enabled by key id: + +- `SYSPRIMS_PTY_PGP_KEY_ID`: PGP signing key id. +- `SYSPRIMS_PTY_GPG_HOMEDIR`: optional alternate GPG home, if needed. + ```bash export SYSPRIMS_PTY_RELEASE_TAG=v$(cat VERSION) make release-download @@ -58,7 +91,25 @@ make release-verify make release-upload ``` -Publishing the GitHub release is a separate explicit maintainer cue: +After upload, the release must still be a draft and must contain the signed +asset inventory: + +```bash +make release-verify-draft-assets MODE=signed +``` + +Before publishing the GitHub release, run the registry-only consumer smoke from +a clean checkout after crates.io shows the package: + +```bash +cargo info --registry crates-io "sysprims-pty@$(cat VERSION)" +make release-smoke-consumer +make release-download-signed +make release-verify +``` + +Publishing the GitHub release is a separate explicit maintainer cue after the +checks above: ```bash gh release edit "v$(cat VERSION)" --draft=false @@ -69,10 +120,12 @@ gh release edit "v$(cat VERSION)" --draft=false - [ ] `vX.Y.Z` is annotated and peels to the reviewed commit. - [ ] GitHub release is public and contains notes, license, SBOM, checksums, signatures, and public keys. -- [ ] `make release-verify` passes from downloaded assets. +- [ ] `make release-download-signed` then `make release-verify` pass from + freshly downloaded signed assets. - [ ] `cargo info --registry crates-io sysprims-pty@X.Y.Z` resolves. - [ ] docs.rs recognizes the published version. -- [ ] A registry-only temporary consumer builds with: +- [ ] `make release-smoke-consumer` passes with a registry-only temporary + consumer that builds with: ```toml [dependencies] diff --git a/scripts/download-signed-release-assets.sh b/scripts/download-signed-release-assets.sh new file mode 100755 index 0000000..f6ab728 --- /dev/null +++ b/scripts/download-signed-release-assets.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG=${1:?"usage: download-signed-release-assets.sh [dest_dir]"} +DEST=${2:-dist/release} +VERSION="${TAG#v}" + +mkdir -p "$DEST" +find "$DEST" -mindepth 1 -maxdepth 1 -exec rm -r {} + + +gh release download "$TAG" --dir "$DEST" --clobber \ + --pattern "sbom-${VERSION}.cdx.json" \ + --pattern 'LICENSE-MIT' \ + --pattern "release-notes-${TAG}.md" \ + --pattern 'SHA256SUMS' \ + --pattern 'SHA256SUMS.minisig' \ + --pattern 'SHA512SUMS' \ + --pattern 'SHA512SUMS.minisig' \ + --pattern 'sysprims-pty-minisign.pub' \ + --pattern 'SHA256SUMS.asc' \ + --pattern 'SHA512SUMS.asc' \ + --pattern 'sysprims-pty-release-signing-key.asc' + +find "$DEST" -maxdepth 1 -type f -print | sort diff --git a/scripts/release-tooling-test.sh b/scripts/release-tooling-test.sh index 8fa475c..f859198 100755 --- a/scripts/release-tooling-test.sh +++ b/scripts/release-tooling-test.sh @@ -38,9 +38,109 @@ git -C "$copy_root" \ -c user.email="release-tooling-test@example.invalid" \ commit -q -m "test fixture" +stub_bin="$TMP_ROOT/stub-bin" +mkdir -p "$stub_bin" +cat > "$stub_bin/minisign" <<'EOF' +#!/bin/sh +exit 0 +EOF +cat > "$stub_bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + case "${GH_STUB_MODE:-}" in + workflow-success) + printf '[{"databaseId":1,"headBranch":"%s","headSha":"%s","status":"completed","conclusion":"success","url":"https://example.invalid/run"}]\n' "$GH_STUB_TAG" "$GH_STUB_SHA" + ;; + workflow-pending) + printf '[{"databaseId":1,"headBranch":"%s","headSha":"%s","status":"in_progress","conclusion":"","url":"https://example.invalid/run"}]\n' "$GH_STUB_TAG" "$GH_STUB_SHA" + ;; + *) + printf '[]\n' + ;; + esac + exit 0 +fi + +if [ "$1" = "release" ] && [ "$2" = "view" ]; then + if printf '%s\n' "$*" | grep -Fq ".isDraft"; then + case "${GH_STUB_MODE:-}" in + release-nondraft) printf 'false\n' ;; + *) printf 'true\n' ;; + esac + exit 0 + fi + if printf '%s\n' "$*" | grep -Fq ".assets[].name"; then + case "${GH_STUB_MODE:-}" in + release-unsigned-good) + printf '%s\n' LICENSE-MIT "release-notes-${GH_STUB_TAG}.md" "sbom-${GH_STUB_VERSION}.cdx.json" + ;; + release-unsigned-missing) + printf '%s\n' LICENSE-MIT "release-notes-${GH_STUB_TAG}.md" + ;; + release-signed-good) + printf '%s\n' \ + LICENSE-MIT \ + "release-notes-${GH_STUB_TAG}.md" \ + "sbom-${GH_STUB_VERSION}.cdx.json" \ + SHA256SUMS \ + SHA256SUMS.minisig \ + SHA512SUMS \ + SHA512SUMS.minisig \ + sysprims-pty-minisign.pub + ;; + release-signed-unexpected) + printf '%s\n' \ + LICENSE-MIT \ + "release-notes-${GH_STUB_TAG}.md" \ + "sbom-${GH_STUB_VERSION}.cdx.json" \ + SHA256SUMS \ + SHA256SUMS.minisig \ + SHA512SUMS \ + SHA512SUMS.minisig \ + sysprims-pty-minisign.pub \ + stale-extra.zip + ;; + *) + printf '\n' + ;; + esac + exit 0 + fi +fi + +if [ "$1" = "release" ] && { [ "$2" = "upload" ] || [ "$2" = "edit" ]; }; then + printf '%s\n' "$*" >> "${GH_STUB_MUTATION_LOG:?}" + exit 0 +fi + +echo "unexpected gh stub call: $*" >&2 +exit 1 +EOF +chmod +x "$stub_bin/minisign" "$stub_bin/gh" + expect_fail "SYSPRIMS_PTY_REQUIRE_TAG requires an annotated tag" \ env SYSPRIMS_PTY_REQUIRE_TAG=1 "$copy_root/scripts/release-guard-tag-version.sh" +git -C "$copy_root" \ + -c user.name="release tooling test" \ + -c user.email="release-tooling-test@example.invalid" \ + tag -a "v${VERSION}" -m "v${VERSION}" +copy_sha="$(git -C "$copy_root" rev-parse "refs/tags/v${VERSION}^{commit}")" +expect_fail "tag workflow verifier requires completed success on exact tag commit" \ + env PATH="$stub_bin:$PATH" \ + GH_STUB_MODE=workflow-pending \ + GH_STUB_TAG="v${VERSION}" \ + GH_STUB_SHA="$copy_sha" \ + "$copy_root/scripts/verify-tag-release-workflow.sh" "v${VERSION}" +env PATH="$stub_bin:$PATH" \ + GH_STUB_MODE=workflow-success \ + GH_STUB_TAG="v${VERSION}" \ + GH_STUB_SHA="$copy_sha" \ + "$copy_root/scripts/verify-tag-release-workflow.sh" "v${VERSION}" >/dev/null +echo "[ok] tag workflow verifier accepts completed success on exact tag commit" + asset_dir="$TMP_ROOT/assets" mkdir -p "$asset_dir" printf 'license\n' > "$asset_dir/LICENSE-MIT" @@ -55,6 +155,26 @@ mkdir -p "$empty_dir" expect_fail "public key verification requires minisign public key" \ "$ROOT/scripts/verify-public-keys.sh" "$empty_dir" +expect_fail "unsigned draft inventory rejects missing assets" \ + env PATH="$stub_bin:$PATH" \ + GH_STUB_MODE=release-unsigned-missing \ + GH_STUB_TAG="v${VERSION}" \ + GH_STUB_VERSION="$VERSION" \ + "$ROOT/scripts/verify-release-draft-assets.sh" "v${VERSION}" unsigned +env PATH="$stub_bin:$PATH" \ + GH_STUB_MODE=release-unsigned-good \ + GH_STUB_TAG="v${VERSION}" \ + GH_STUB_VERSION="$VERSION" \ + "$ROOT/scripts/verify-release-draft-assets.sh" "v${VERSION}" unsigned >/dev/null +echo "[ok] unsigned draft inventory accepts exact assets" + +expect_fail "signed draft inventory rejects unexpected assets" \ + env PATH="$stub_bin:$PATH" \ + GH_STUB_MODE=release-signed-unexpected \ + GH_STUB_TAG="v${VERSION}" \ + GH_STUB_VERSION="$VERSION" \ + "$ROOT/scripts/verify-release-draft-assets.sh" "v${VERSION}" signed + pgp_dir="$TMP_ROOT/partial-pgp" mkdir -p "$pgp_dir" printf 'x y\n' > "$pgp_dir/SHA256SUMS" @@ -64,14 +184,24 @@ printf 'dummy\n' > "$pgp_dir/SHA512SUMS.minisig" printf '%s\n' 'untrusted comment: fake public key for negative test' > "$pgp_dir/sysprims-pty-minisign.pub" printf '%s\n' '-----BEGIN PGP PUBLIC KEY BLOCK-----' '-----END PGP PUBLIC KEY BLOCK-----' \ > "$pgp_dir/sysprims-pty-release-signing-key.asc" -stub_bin="$TMP_ROOT/stub-bin" -mkdir -p "$stub_bin" -cat > "$stub_bin/minisign" <<'EOF' -#!/bin/sh -exit 0 -EOF -chmod +x "$stub_bin/minisign" expect_fail "signature verification requires complete PGP signature set" \ env PATH="$stub_bin:$PATH" "$ROOT/scripts/verify-signatures.sh" "$pgp_dir" +upload_dir="$TMP_ROOT/upload" +mkdir -p "$upload_dir" +for file in SHA256SUMS SHA256SUMS.minisig SHA512SUMS SHA512SUMS.minisig sysprims-pty-minisign.pub "release-notes-v${VERSION}.md"; do + printf 'dummy\n' > "$upload_dir/$file" +done +mutation_log="$TMP_ROOT/gh-mutations.log" +expect_fail "upload refuses non-draft release before mutation" \ + env PATH="$stub_bin:$PATH" \ + GH_STUB_MODE=release-nondraft \ + GH_STUB_MUTATION_LOG="$mutation_log" \ + "$ROOT/scripts/upload-release-assets.sh" "v${VERSION}" "$upload_dir" +if [ -e "$mutation_log" ]; then + echo "error: non-draft upload attempted release mutation" >&2 + exit 1 +fi +echo "[ok] non-draft upload exits before release mutation" + echo "[ok] release tooling negative controls passed" diff --git a/scripts/smoke-registry-consumer.sh b/scripts/smoke-registry-consumer.sh new file mode 100755 index 0000000..2d1d4a5 --- /dev/null +++ b/scripts/smoke-registry-consumer.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +VERSION="$(tr -d '\r\n' < "$ROOT/VERSION")" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/sysprims-pty-consumer.XXXXXX")" +cleanup() { + if [ -n "${WORK_DIR:-}" ] && [ -d "$WORK_DIR" ]; then + find "$WORK_DIR" -mindepth 1 -maxdepth 1 -exec rm -r {} + + rmdir "$WORK_DIR" + fi +} +trap cleanup EXIT HUP INT TERM + +cd "$WORK_DIR" +cargo init --bin --quiet + +cat > Cargo.toml < src/main.rs <<'EOF' +use portable_pty::CommandBuilder; + +fn main() { + let _cmd = CommandBuilder::new("true"); +} +EOF + +cargo generate-lockfile +cargo build --locked +cargo tree --locked | grep -F "sysprims-pty v${VERSION}" + +if cargo tree --locked | grep -F "git+"; then + echo "error: registry consumer resolved a git dependency" >&2 + exit 1 +fi + +echo "[ok] registry-only consumer builds with sysprims-pty ${VERSION}" diff --git a/scripts/upload-release-assets.sh b/scripts/upload-release-assets.sh index ab2602c..76678de 100755 --- a/scripts/upload-release-assets.sh +++ b/scripts/upload-release-assets.sh @@ -5,6 +5,11 @@ TAG=${1:?"usage: upload-release-assets.sh [dir]"} DIR=${2:-dist/release} test -d "$DIR" +if [ "$(gh release view "$TAG" --json isDraft --jq '.isDraft')" != "true" ]; then + echo "error: release ${TAG} is not draft; refusing to mutate published release" >&2 + exit 1 +fi + cd "$DIR" required=( @@ -32,5 +37,7 @@ done gh release upload "$TAG" "${uploads[@]}" --clobber gh release edit "$TAG" --notes-file "release-notes-${TAG}.md" +cd - >/dev/null +"$(dirname "$0")/verify-release-draft-assets.sh" "$TAG" signed echo "[ok] signed assets uploaded; release remains draft until an explicit undraft cue" diff --git a/scripts/verify-release-draft-assets.sh b/scripts/verify-release-draft-assets.sh new file mode 100755 index 0000000..000a0d6 --- /dev/null +++ b/scripts/verify-release-draft-assets.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG=${1:?"usage: verify-release-draft-assets.sh "} +MODE=${2:?"usage: verify-release-draft-assets.sh "} +VERSION="${TAG#v}" + +is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" +if [ "$is_draft" != "true" ]; then + echo "error: release ${TAG} is not draft" >&2 + exit 1 +fi + +case "$MODE" in +unsigned) + required=( + "LICENSE-MIT" + "release-notes-${TAG}.md" + "sbom-${VERSION}.cdx.json" + ) + allowed="^(LICENSE-MIT|release-notes-${TAG}\.md|sbom-${VERSION}\.cdx\.json)$" + ;; +signed) + required=( + "LICENSE-MIT" + "release-notes-${TAG}.md" + "sbom-${VERSION}.cdx.json" + "SHA256SUMS" + "SHA256SUMS.minisig" + "SHA512SUMS" + "SHA512SUMS.minisig" + "sysprims-pty-minisign.pub" + ) + allowed="^(LICENSE-MIT|release-notes-${TAG}\.md|sbom-${VERSION}\.cdx\.json|SHA256SUMS|SHA256SUMS\.minisig|SHA256SUMS\.asc|SHA512SUMS|SHA512SUMS\.minisig|SHA512SUMS\.asc|sysprims-pty-minisign\.pub|sysprims-pty-release-signing-key\.asc)$" + ;; +*) + echo "error: unknown asset verification mode: $MODE" >&2 + exit 1 + ;; +esac + +assets="$(gh release view "$TAG" --json assets --jq '.assets[].name')" +for file in "${required[@]}"; do + if ! printf '%s\n' "$assets" | grep -Fxq "$file"; then + echo "error: release ${TAG} is missing asset ${file}" >&2 + echo "$assets" >&2 + exit 1 + fi +done + +if printf '%s\n' "$assets" | grep -Ev "$allowed" >/dev/null; then + echo "error: release ${TAG} has unexpected assets for ${MODE} mode" >&2 + echo "$assets" >&2 + exit 1 +fi + +echo "[ok] release ${TAG} is draft with expected ${MODE} assets" diff --git a/scripts/verify-tag-release-workflow.sh b/scripts/verify-tag-release-workflow.sh new file mode 100755 index 0000000..b5c288f --- /dev/null +++ b/scripts/verify-tag-release-workflow.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG=${1:-${SYSPRIMS_PTY_RELEASE_TAG:-}} +if [ -z "$TAG" ]; then + echo "usage: verify-tag-release-workflow.sh " >&2 + exit 2 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="${SYSPRIMS_PTY_REPO_ROOT:-$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel)}" +cd "$PROJECT_ROOT" + +VERSION="${TAG#v}" +if [ "$TAG" != "v${VERSION}" ]; then + echo "error: release tag must start with v: ${TAG}" >&2 + exit 1 +fi + +expected="$(git rev-parse "refs/tags/${TAG}^{commit}")" +runs="$(gh run list \ + --workflow Release \ + --event push \ + --json databaseId,headBranch,headSha,status,conclusion,url \ + --limit 50)" + +RUNS_JSON="$runs" EXPECTED_SHA="$expected" TAG="$TAG" python3 - <<'PY' +import json +import os +import sys + +runs = json.loads(os.environ["RUNS_JSON"]) +expected = os.environ["EXPECTED_SHA"] +tag = os.environ["TAG"] + +matches = [ + run + for run in runs + if run.get("headBranch") == tag and run.get("headSha") == expected +] +if not matches: + print( + f"error: no Release workflow run found for {tag} at {expected}", + file=sys.stderr, + ) + raise SystemExit(1) + +successful = [ + run + for run in matches + if run.get("status") == "completed" and run.get("conclusion") == "success" +] +if not successful: + print( + f"error: Release workflow for {tag} at {expected} is not completed/success", + file=sys.stderr, + ) + for run in matches: + print( + f" run {run.get('databaseId')} status={run.get('status')} " + f"conclusion={run.get('conclusion')} url={run.get('url')}", + file=sys.stderr, + ) + raise SystemExit(1) + +run = successful[0] +print( + f"[ok] Release workflow run {run.get('databaseId')} succeeded for {tag} at {expected}: {run.get('url')}" +) +PY