diff --git a/CHANGELOG.md b/CHANGELOG.md index e494399..b6c5873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.10] - 2026-07-30 + +### Fixed + +- **Release checksum verification is fail-closed.** `make release-verify-checksums` now requires non-empty `SHA256SUMS` and `SHA512SUMS` and runs `shasum -c` without piping away the exit status, so a corrupted asset or missing/empty manifest fails the target. +- **Maintainer Make upload targets require full verification.** `make release-upload` and `make release-upload-provenance` both depend on `make release-verify` (checksums + signatures + keys). Tag CI may still publish unsigned platform archives and the install script before the manual sign/verify/upload phase; this change gates the maintainer Make upload recipes only. + +### Added + +- Composite `make release-verify` target and a hermetic regression harness (`scripts/test-release-verify-checksums.sh`) covering valid, corrupted, absent, and empty manifests. The harness is wired into `make precommit` (the gate CI runs). + ## [0.4.9] - 2026-07-29 ### Changed @@ -157,7 +168,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Maintenance note:** This file is pruned to the latest 10 releases. For older entries, see `docs/releases/`. -[Unreleased]: https://github.com/3leaps/sfetch/compare/v0.4.9...HEAD +[Unreleased]: https://github.com/3leaps/sfetch/compare/v0.4.10...HEAD +[0.4.10]: https://github.com/3leaps/sfetch/compare/v0.4.9...v0.4.10 [0.4.9]: https://github.com/3leaps/sfetch/compare/v0.4.8...v0.4.9 [0.4.8]: https://github.com/3leaps/sfetch/compare/v0.4.7...v0.4.8 [0.4.7]: https://github.com/3leaps/sfetch/compare/v0.4.6...v0.4.7 diff --git a/Makefile b/Makefile index 26ff8ba..e878ba7 100644 --- a/Makefile +++ b/Makefile @@ -58,10 +58,10 @@ CORPUS_DEST ?= test-corpus .PHONY: precommit prepush version corpus corpus-all corpus-dryrun corpus-validate .PHONY: release release-download release-checksums release-verify-checksums release-sign .PHONY: release-notes release-upload release-upload-provenance release-export-key release-export-minisign-key release-export-keys -.PHONY: release-verify-key release-verify-minisign-pubkey release-verify-keys release-verify-signatures +.PHONY: release-verify-key release-verify-minisign-pubkey release-verify-keys release-verify-signatures release-verify .PHONY: release-clean bootstrap-script build-all gosec gosec-high update-scoop-manifest .PHONY: version-check version-set version-patch version-minor version-major -.PHONY: print-sfetch-version +.PHONY: print-sfetch-version test-release-verify-checksums all: build @@ -205,6 +205,8 @@ precommit: ## Run pre-commit checks (goneat assess + Go tests + build) go test -v -race ./... $(MAKE) gosec-high $(MAKE) build-all + # CI runs make precommit (not prepush); keep fail-closed release-verify regression on this path. + $(MAKE) test-release-verify-checksums @echo "[ok] Pre-commit checks passed" prepush: precommit ## Run pre-push checks (same as precommit + security) @@ -250,19 +252,29 @@ bootstrap-script: ## Copy install script into release directory release-checksums: bootstrap-script ## Generate SHA256SUMS and SHA512SUMS go run ./scripts/cmd/generate-checksums --dir $(DIST_RELEASE) -release-verify-checksums: ## Verify checksums in dist/release +release-verify-checksums: ## Verify checksums in dist/release (fail-closed; portable /bin/sh) @if [ ! -d "$(DIST_RELEASE)" ]; then echo "error: $(DIST_RELEASE) not found (run make release-download first)" >&2; exit 1; fi @echo "Verifying checksums in $(DIST_RELEASE)..." - @cd $(DIST_RELEASE) && \ - if [ -f SHA256SUMS ]; then \ - echo "=== SHA256SUMS ===" && \ - shasum -a 256 -c SHA256SUMS 2>&1 | grep -v ': OK$$' || echo "All SHA256 checksums OK"; \ - fi && \ - if [ -f SHA512SUMS ]; then \ - echo "=== SHA512SUMS ===" && \ - shasum -a 512 -c SHA512SUMS 2>&1 | grep -v ': OK$$' || echo "All SHA512 checksums OK"; \ - fi - @echo "[ok] Checksum verification complete" + @set -eu; \ + cd "$(DIST_RELEASE)"; \ + if [ ! -s SHA256SUMS ]; then \ + echo "error: SHA256SUMS missing or empty in $(DIST_RELEASE)" >&2; \ + exit 1; \ + fi; \ + if [ ! -s SHA512SUMS ]; then \ + echo "error: SHA512SUMS missing or empty in $(DIST_RELEASE)" >&2; \ + exit 1; \ + fi; \ + echo "=== SHA256SUMS ==="; \ + shasum -a 256 -c SHA256SUMS; \ + echo "=== SHA512SUMS ==="; \ + shasum -a 512 -c SHA512SUMS; \ + echo "[ok] Checksum verification complete" + +# Negative + positive regression for release-verify-checksums (exit status only). +# Wired into precommit because sfetch CI runs make precommit, not prepush. +test-release-verify-checksums: ## Regression: fail-closed checksum verify (corrupt/absent/empty) + @./scripts/test-release-verify-checksums.sh release-notes: ## Copy release notes into dist/release @if [ -z "$(RELEASE_TAG)" ]; then echo "error: RELEASE_TAG not set" >&2; exit 1; fi @@ -319,13 +331,15 @@ release-verify-keys: release-verify-key ## Verify all exported public keys release-verify-signatures: ## Verify minisign and PGP signatures on checksum manifests ./scripts/verify-signatures.sh $(DIST_RELEASE) -release-upload: release-notes release-verify-key ## Upload all assets and update release notes +release-verify: release-verify-checksums release-verify-signatures release-verify-keys ## Full post-signing release verification + +release-upload: release-notes release-verify ## Upload all assets (requires full verification chain) ./scripts/upload-release-assets.sh $(RELEASE_TAG) $(DIST_RELEASE) @echo "" @echo "📝 Updating Scoop manifest..." @$(MAKE) update-scoop-manifest -release-upload-provenance: release-notes ## Upload provenance only (manifests, signatures, keys, notes) +release-upload-provenance: release-notes release-verify ## Upload provenance only (requires full verification chain) ./scripts/release-upload-provenance.sh $(RELEASE_TAG) $(DIST_RELEASE) update-scoop-manifest: ## Update Scoop bucket manifest with the current release version (requires sibling ../scoop-bucket) diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index ca46b40..ed56011 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -69,7 +69,7 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to make release-checksums ``` -4. **Verify checksums** +4. **Verify checksums** (fail-closed: missing/empty manifests and bad hashes fail) ```bash make release-verify-checksums ``` @@ -96,6 +96,12 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to make release-verify-keys ``` + Or run the full post-signing chain in one step: + + ```bash + make release-verify + ``` + 9. **Copy release notes** (requires `docs/releases/$RELEASE_TAG.md`) ```bash make release-notes @@ -105,12 +111,13 @@ export SFETCH_GPG_HOMEDIR=/path/to/custom/gpg/homedir # optional, defaults to ```bash make release-upload ``` - > **Note:** This uploads ALL assets with `--clobber`, including binaries CI already uploaded. + > **Note:** This target depends on `release-verify` (checksums + signatures + keys). + > It uploads ALL assets with `--clobber`, including binaries CI already uploaded. > This is intentional for idempotency - rerun safely to fix any mistakes. > > If `../scoop-bucket` is present, this target also runs `make update-scoop-manifest` at the end so the bucket is ready for commit/push immediately after release upload. > - > To upload provenance only (manifests, signatures, keys, notes): + > To upload provenance only (manifests, signatures, keys, notes) — also requires `release-verify`: > ```bash > make release-upload-provenance > ``` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 38da5c7..ba613fc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,30 @@ +## v0.4.10 + +### Summary +Release-path automation hardening: checksum verification fails closed, and both maintainer Make upload targets require the full verification chain. Tag CI may still publish unsigned archives before the manual sign/verify phase. No user-facing feature or API changes. + +### Highlights + +**Release verification** +- `make release-verify-checksums` requires non-empty `SHA256SUMS` and `SHA512SUMS` and fails when `shasum -c` fails (no masked exit status). +- New composite `make release-verify` (checksums + signatures + keys). +- `make release-upload` and `make release-upload-provenance` both depend on `release-verify` (maintainer Make paths only; not initial CI asset publish). +- Hermetic regression harness for corrupt/absent/empty manifests runs in `make precommit` (and therefore CI). + +### Install + +```bash +curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash +``` + +Or self-update: + +```bash +sfetch --self-update --yes +``` + +--- + ## v0.4.9 ### Summary diff --git a/VERSION b/VERSION index 76914dd..e8423da 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.9 +0.4.10 diff --git a/docs/releases/v0.4.10.md b/docs/releases/v0.4.10.md new file mode 100644 index 0000000..56646a4 --- /dev/null +++ b/docs/releases/v0.4.10.md @@ -0,0 +1,27 @@ +## Summary +Release-path automation hardening: checksum verification fails closed, and both maintainer Make upload targets require the full verification chain. Tag CI may still publish unsigned archives before the manual sign/verify phase. No user-facing feature or API changes. + +## Highlights + +### Release verification +- `make release-verify-checksums` requires non-empty `SHA256SUMS` and `SHA512SUMS` and fails when `shasum -c` fails (no masked exit status). +- New composite `make release-verify` runs checksums, signatures, and key verification together. +- `make release-upload` and `make release-upload-provenance` both depend on `release-verify`. These are the maintainer Make upload recipes; they do not change initial tag CI publication of unsigned platform archives and the install script. +- Hermetic regression harness (`scripts/test-release-verify-checksums.sh`) covers valid, corrupted, absent, and empty manifests for both SHA256SUMS and SHA512SUMS. The harness is part of `make precommit`, which is the gate CI runs. + +## Validation +- `make precommit` green (includes fail-closed checksum regression under `/bin/sh`) +- `make prepush` green +- `actionlint` green on workflow files + +## Install + +```bash +curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash +``` + +Or self-update: + +```bash +sfetch --self-update --yes +``` diff --git a/scripts/test-release-verify-checksums.sh b/scripts/test-release-verify-checksums.sh new file mode 100755 index 0000000..c5f78e4 --- /dev/null +++ b/scripts/test-release-verify-checksums.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Regression harness for make release-verify-checksums (fail-closed). +# Asserts exit status only (zero vs non-zero) — not make-specific codes or +# presence/absence of success strings. Cover both SHA256SUMS and SHA512SUMS. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +pass() { + echo "PASS: $*" +} + +run_verify() { + local dir="$1" + # Force /bin/sh so Linux CI (dash) is exercised even on bash hosts. + make -s release-verify-checksums DIST_RELEASE="$dir" SHELL="${MAKE_SHELL:-/bin/sh}" +} + +# Initialize before trap so early exit never hits unbound variables under set -u. +valid="" +corrupt="" +absent256="" +absent512="" +empty256="" +empty512="" +trap 'rm -rf "${valid:-}" "${corrupt:-}" "${absent256:-}" "${absent512:-}" "${empty256:-}" "${empty512:-}" 2>/dev/null || true' EXIT + +# --- Case 1: valid fixture exits 0 --- +valid="$(mktemp -d "${TMPDIR:-/tmp}/sft-rv-valid.XXXXXX")" + +printf 'payload-a\n' >"$valid/a.bin" +printf 'payload-b\n' >"$valid/b.bin" +( + cd "$valid" + shasum -a 256 a.bin b.bin >SHA256SUMS + shasum -a 512 a.bin b.bin >SHA512SUMS +) +if run_verify "$valid"; then + pass "valid fixture exits 0" +else + fail "valid fixture should exit 0" +fi + +# --- Case 2: corrupted checksum exits non-zero --- +corrupt="$(mktemp -d "${TMPDIR:-/tmp}/sft-rv-corrupt.XXXXXX")" +cp -R "$valid/." "$corrupt/" +python3 - "$corrupt/SHA256SUMS" <<'PY' +import pathlib, sys +p = pathlib.Path(sys.argv[1]) +lines = p.read_text().splitlines(True) +c = lines[0][0] +lines[0] = ('0' if c != '0' else '1') + lines[0][1:] +p.write_text(''.join(lines)) +PY +if run_verify "$corrupt"; then + fail "corrupted checksum should exit non-zero" +else + pass "corrupted checksum exits non-zero" +fi + +# --- Case 3: absent SHA256SUMS exits non-zero --- +absent256="$(mktemp -d "${TMPDIR:-/tmp}/sft-rv-absent256.XXXXXX")" +cp "$valid/a.bin" "$absent256/" +cp "$valid/b.bin" "$absent256/" +cp "$valid/SHA512SUMS" "$absent256/" +if run_verify "$absent256"; then + fail "absent SHA256SUMS should exit non-zero" +else + pass "absent SHA256SUMS exits non-zero" +fi + +# --- Case 4: absent SHA512SUMS exits non-zero --- +absent512="$(mktemp -d "${TMPDIR:-/tmp}/sft-rv-absent512.XXXXXX")" +cp "$valid/a.bin" "$absent512/" +cp "$valid/b.bin" "$absent512/" +cp "$valid/SHA256SUMS" "$absent512/" +if run_verify "$absent512"; then + fail "absent SHA512SUMS should exit non-zero" +else + pass "absent SHA512SUMS exits non-zero" +fi + +# --- Case 5: empty SHA256SUMS exits non-zero --- +empty256="$(mktemp -d "${TMPDIR:-/tmp}/sft-rv-empty256.XXXXXX")" +cp -R "$valid/." "$empty256/" +: >"$empty256/SHA256SUMS" +if run_verify "$empty256"; then + fail "empty SHA256SUMS should exit non-zero" +else + pass "empty SHA256SUMS exits non-zero" +fi + +# --- Case 6: empty SHA512SUMS exits non-zero --- +empty512="$(mktemp -d "${TMPDIR:-/tmp}/sft-rv-empty512.XXXXXX")" +cp -R "$valid/." "$empty512/" +: >"$empty512/SHA512SUMS" +if run_verify "$empty512"; then + fail "empty SHA512SUMS should exit non-zero" +else + pass "empty SHA512SUMS exits non-zero" +fi + +echo "[ok] release-verify-checksums regression harness complete"