diff --git a/.github/scripts/patch-homebrew-cask.sh b/.github/scripts/patch-homebrew-cask.sh new file mode 100755 index 0000000..f876852 --- /dev/null +++ b/.github/scripts/patch-homebrew-cask.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +error() { + echo "::error::$*" >&2 + exit 1 +} + +if [ "$#" -ne 3 ]; then + error "Usage: $0 " +fi + +ARCHIVE=$1 +CHECKSUMS=$2 +CASK_FILE=$3 + +[ -f "$ARCHIVE" ] || error "Darwin archive not found: $ARCHIVE" +[ -f "$CHECKSUMS" ] || error "Release manifest not found: $CHECKSUMS" +[ -f "$CASK_FILE" ] || error "Cask file not found: $CASK_FILE" + +ARCHIVE_SHA=$(sha256sum "$ARCHIVE" | awk '{ print $1 }') +[[ "$ARCHIVE_SHA" =~ ^[0-9a-f]{64}$ ]] || \ + error "Computed SHA is not a valid 64-character hex string" + +ARCHIVE_NAME=$(basename "$ARCHIVE") +mapfile -t MANIFEST_SHAS < <( + awk -v archive="$ARCHIVE_NAME" '$2 == archive { print $1 }' "$CHECKSUMS" +) + +if [ "${#MANIFEST_SHAS[@]}" -ne 1 ]; then + error "Release manifest must contain exactly one entry for $ARCHIVE_NAME" +fi +if [ "${MANIFEST_SHAS[0]}" != "$ARCHIVE_SHA" ]; then + error "Release manifest SHA does not match downloaded darwin_arm64 archive" +fi + +PATCHED_FILE=$(mktemp "${CASK_FILE}.patched.XXXXXX") +trap 'rm -f "$PATCHED_FILE"' EXIT + +awk -v arm64="$ARCHIVE_SHA" ' + { lines[NR] = $0 } + /^[[:space:]]*url "[^"]*darwin_arm64[^"]*"/ { + count++ + candidate = NR - 1 + if (lines[candidate] ~ /^[[:space:]]*sha256 "[0-9a-f]+"[[:space:]]*$/) { + target = candidate + } + } + END { + if (count != 1 || !target) { + printf "::error::Cask layout changed: found %d darwin_arm64 URL stanza(s); exactly one immediately preceded by sha256 is required\n", count + 0 > "/dev/stderr" + exit 1 + } + sub(/sha256 "[^"]*"/, "sha256 \"" arm64 "\"", lines[target]) + for (i = 1; i <= NR; i++) { + print lines[i] + } + } +' "$CASK_FILE" > "$PATCHED_FILE" + +if ! awk -v arm64="$ARCHIVE_SHA" ' + { lines[NR] = $0 } + /^[[:space:]]*url "[^"]*darwin_arm64[^"]*"/ { + count++ + sha_line = lines[NR - 1] + if (sha_line !~ /^[[:space:]]*sha256 "[0-9a-f]+"[[:space:]]*$/) { + bad = 1 + } else { + sub(/^[[:space:]]*sha256 "/, "", sha_line) + sub(/"[[:space:]]*$/, "", sha_line) + if (sha_line != arm64) { + bad = 1 + } + } + } + END { exit (count == 1 && !bad) ? 0 : 1 } +' "$PATCHED_FILE"; then + error "SHA verification failed: darwin_arm64 stanza does not carry the computed SHA" +fi + +mv "$PATCHED_FILE" "$CASK_FILE" +echo "Cask patched successfully with darwin_arm64 SHA: $ARCHIVE_SHA" diff --git a/.github/scripts/patch-homebrew-cask_test.sh b/.github/scripts/patch-homebrew-cask_test.sh new file mode 100755 index 0000000..bd8a7c3 --- /dev/null +++ b/.github/scripts/patch-homebrew-cask_test.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +PATCHER="$SCRIPT_DIR/patch-homebrew-cask.sh" +FIXTURE="$SCRIPT_DIR/testdata/replicator-v0.5.0.rb" +ARCHIVE_NAME="replicator_0.5.0_darwin_arm64.tar.gz" +LINUX_ARM64_SHA="d35cf51192f4bc3eb92d32c2a63304fdbc561243a2bb8e406d0a5c7f9d1a83f1" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +if [ ! -x "$PATCHER" ]; then + fail "patcher is not executable: $PATCHER" +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +new_case() { + CASE_DIR="$WORK/$1" + mkdir -p "$CASE_DIR" + cp "$FIXTURE" "$CASE_DIR/replicator.rb" + printf 'signed darwin archive fixture\n' > "$CASE_DIR/$ARCHIVE_NAME" + ARCHIVE_SHA=$(sha256sum "$CASE_DIR/$ARCHIVE_NAME" | awk '{ print $1 }') + printf '%s %s\n' "$ARCHIVE_SHA" "$ARCHIVE_NAME" > "$CASE_DIR/checksums.txt" +} + +assert_success() { + "$PATCHER" \ + "$CASE_DIR/$ARCHIVE_NAME" \ + "$CASE_DIR/checksums.txt" \ + "$CASE_DIR/replicator.rb" >/dev/null +} + +assert_failure_preserves_cask() { + cp "$CASE_DIR/replicator.rb" "$CASE_DIR/before.rb" + if "$PATCHER" \ + "$CASE_DIR/$ARCHIVE_NAME" \ + "$CASE_DIR/checksums.txt" \ + "$CASE_DIR/replicator.rb" >"$CASE_DIR/stdout" 2>"$CASE_DIR/stderr"; then + fail "$1: expected failure" + fi + grep -q '^::error::' "$CASE_DIR/stderr" || \ + fail "$1: failure did not emit an ::error:: annotation" + cmp -s "$CASE_DIR/before.rb" "$CASE_DIR/replicator.rb" || \ + fail "$1: original cask changed on failure" +} + +new_case happy +assert_success +sed "7c\\ sha256 \"$ARCHIVE_SHA\"" "$FIXTURE" > "$CASE_DIR/expected.rb" +cmp -s "$CASE_DIR/expected.rb" "$CASE_DIR/replicator.rb" || \ + fail "happy: patched cask differs from exact expected fixture" + +new_case stray-comment +printf '\n# note: darwin_arm64 builds are notarized\n' >> "$CASE_DIR/replicator.rb" +assert_success +grep -q "sha256 \"$LINUX_ARM64_SHA\"" "$CASE_DIR/replicator.rb" || \ + fail "stray-comment: linux_arm64 SHA changed" + +new_case trailing-comment +sed -i '/linux_arm64.tar.gz"$/s/$/ # darwin_arm64/' "$CASE_DIR/replicator.rb" +assert_success +grep -q "sha256 \"$LINUX_ARM64_SHA\"" "$CASE_DIR/replicator.rb" || \ + fail "trailing-comment: linux_arm64 SHA changed" + +new_case missing-darwin +sed -i '/darwin_arm64/d' "$CASE_DIR/replicator.rb" +assert_failure_preserves_cask "missing darwin URL" + +new_case duplicate-darwin +printf ' url "https://example.invalid/replicator_darwin_arm64.tar.gz"\n' >> \ + "$CASE_DIR/replicator.rb" +assert_failure_preserves_cask "duplicate darwin URL" + +new_case reordered +awk ' + NR == 7 { sha = $0; next } + NR == 8 { print; print sha; next } + { print } +' "$CASE_DIR/replicator.rb" > "$CASE_DIR/reordered.rb" +mv "$CASE_DIR/reordered.rb" "$CASE_DIR/replicator.rb" +assert_failure_preserves_cask "URL before sha256" + +new_case stale-candidate +cat > "$CASE_DIR/replicator.rb" <<'CASK' +cask "replicator" do + sha256 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + url "https://example.invalid/replicator_linux_arm64.tar.gz" + url "https://example.invalid/replicator_darwin_arm64.tar.gz" + sha256 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +end +CASK +assert_failure_preserves_cask "stale checksum candidate" + +new_case missing-manifest +: > "$CASE_DIR/checksums.txt" +assert_failure_preserves_cask "missing manifest entry" + +new_case duplicate-manifest +cat "$CASE_DIR/checksums.txt" >> "$CASE_DIR/checksums.txt.copy" +cat "$CASE_DIR/checksums.txt" >> "$CASE_DIR/checksums.txt.copy" +mv "$CASE_DIR/checksums.txt.copy" "$CASE_DIR/checksums.txt" +assert_failure_preserves_cask "duplicate manifest entry" + +new_case mismatched-manifest +printf '%064d %s\n' 0 "$ARCHIVE_NAME" > "$CASE_DIR/checksums.txt" +assert_failure_preserves_cask "mismatched manifest entry" + +echo "PASS: Homebrew cask integrity regression suite" diff --git a/.github/scripts/testdata/replicator-v0.5.0.rb b/.github/scripts/testdata/replicator-v0.5.0.rb new file mode 100644 index 0000000..5c6923c --- /dev/null +++ b/.github/scripts/testdata/replicator-v0.5.0.rb @@ -0,0 +1,41 @@ +# This file was generated by GoReleaser. DO NOT EDIT. +cask "replicator" do + version "0.5.0" + + on_macos do + on_arm do + sha256 "ea3278e2189326de65d79cfd4da9ff42fd91530a138cb9863eadc1c184eda569" + url "https://github.com/unbound-force/replicator/releases/download/v#{version}/replicator_#{version}_darwin_arm64.tar.gz" + end + end + + on_linux do + on_intel do + sha256 "db3f96fcd316e33fc8b0b4f6805ee07313f5338fa13c46889fc8473d7a5f9852" + url "https://github.com/unbound-force/replicator/releases/download/v#{version}/replicator_#{version}_linux_amd64.tar.gz" + end + on_arm do + sha256 "d35cf51192f4bc3eb92d32c2a63304fdbc561243a2bb8e406d0a5c7f9d1a83f1" + url "https://github.com/unbound-force/replicator/releases/download/v#{version}/replicator_#{version}_linux_arm64.tar.gz" + end + end + + name "replicator" + desc "Multi-agent coordination for AI coding agents" + homepage "https://github.com/unbound-force/replicator" + + livecheck do + skip "Auto-generated on release." + end + + binary "replicator" + + postflight do + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/replicator"] + end + end + + # No zap stanza required + +end diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45685f2..7f8c2f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: go install golang.org/x/vuln/cmd/govulncheck@3e6f44f962742443c11ae2261f02e0c917aeb2bc # v1.5.0 govulncheck ./... + - name: Test Homebrew cask integrity patching + run: .github/scripts/patch-homebrew-cask_test.sh + - name: Test run: go test ./... -count=1 -race -coverprofile=coverage.out diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e9ac0e..1e663a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,8 +200,8 @@ jobs: publish-cask: name: Publish Homebrew Cask - # No checkout needed — all inputs come from GitHub Release - # assets (downloaded via gh) and the homebrew-tap repo (cloned separately). + # Release inputs come from GitHub assets; checkout provides the tested + # cask integrity script used by both this job and pull-request CI. runs-on: ubuntu-latest needs: [preflight, release, sign-macos] if: >- @@ -214,30 +214,27 @@ jobs: env: RELEASE_TAG: ${{ needs.preflight.outputs.tag }} steps: - - name: Download final darwin archive and compute SHA - id: sha + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download final darwin archive and release manifest run: | set -euo pipefail VERSION="${RELEASE_TAG#v}" gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --pattern "replicator_*_darwin_arm64.tar.gz" --dir ./artifacts + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern "checksums.txt" --dir ./artifacts ARCHIVE="./artifacts/replicator_${VERSION}_darwin_arm64.tar.gz" if [ ! -f "$ARCHIVE" ]; then echo "::error::darwin_arm64 archive not found in release assets" exit 1 fi - - ARM64_SHA=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}') - - if [[ ! "$ARM64_SHA" =~ ^[0-9a-f]{64}$ ]]; then - echo "::error::Computed SHA is not a valid 64-character hex string: $ARM64_SHA" + if [ ! -f "./artifacts/checksums.txt" ]; then + echo "::error::checksums.txt not found in release assets" exit 1 fi - - echo "darwin_arm64 SHA256: $ARM64_SHA" - echo "arm64_sha=$ARM64_SHA" >> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -257,31 +254,12 @@ jobs: - name: Patch cask with actual SHA run: | set -euo pipefail + VERSION="${RELEASE_TAG#v}" + ARCHIVE="./artifacts/replicator_${VERSION}_darwin_arm64.tar.gz" CASK_FILE="./cask-staging/replicator.rb" - if [ -z "$ARM64_SHA" ]; then - echo "::error::arm64_sha output is empty — SHA computation step may have failed" - exit 1 - fi - - awk -v arm64="$ARM64_SHA" ' - /darwin_arm64/ { found_arm64=1 } - /sha256/ && found_arm64 { - sub(/sha256 "[^"]*"/, "sha256 \"" arm64 "\"") - found_arm64=0 - } - { print } - ' "$CASK_FILE" > "${CASK_FILE}.patched" - mv "${CASK_FILE}.patched" "$CASK_FILE" - - if ! grep -q "sha256 \"$ARM64_SHA\"" "$CASK_FILE"; then - echo "::error::SHA patching failed — expected SHA not found in darwin_arm64 section of cask file" - exit 1 - fi - - echo "Cask patched successfully with darwin_arm64 SHA: $ARM64_SHA" - env: - ARM64_SHA: ${{ steps.sha.outputs.arm64_sha }} + .github/scripts/patch-homebrew-cask.sh \ + "$ARCHIVE" ./artifacts/checksums.txt "$CASK_FILE" - name: Push to Homebrew tap run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b9a1a..dddc1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,21 @@ this project adheres to [Semantic Versioning](https://semver.org/). ## Unreleased ### Fixed -- Homebrew cask SHA-256 mismatch after macOS code signing replaces release - archive. Extracted cask publishing from `sign-macos` into a dedicated - `publish-cask` job that computes the SHA from the final release artifact, - eliminating the TOCTOU gap between signing and cask publication. +- Homebrew cask published with checksums on the wrong stanza. The + `publish-cask` job scanned forward from the `darwin_arm64` marker for the + next `sha256` line, but the cask emits each stanza's `sha256` before its + `url` — so darwin's checksum was written onto the `linux_amd64` line while + darwin kept its stale pre-signing value. This broke `brew install` on both + macOS arm64 and Linux amd64 in v0.5.0. A tested integrity script now + requires exactly one darwin URL immediately preceded by its `sha256`, + cross-checks the release manifest, fails closed on layout drift, and runs + its regression fixtures in pull-request CI. + (Fixes [#87](https://github.com/unbound-force/replicator/issues/87)) +- TOCTOU gap between macOS code signing and cask publication. Extracted cask + publishing from `sign-macos` into a dedicated `publish-cask` job that + computes the SHA from the final release artifact. Shipped in v0.5.0; note + that Homebrew installs remained broken until the stanza-targeting fix + above. (Fixes [#81](https://github.com/unbound-force/replicator/issues/81)) ### Added diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/.openspec.yaml b/openspec/changes/fix-cask-sha-stanza-targeting/.openspec.yaml new file mode 100644 index 0000000..f4f1c68 --- /dev/null +++ b/openspec/changes/fix-cask-sha-stanza-targeting/.openspec.yaml @@ -0,0 +1,2 @@ +schema: unbound-force +created: 2026-08-22 diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/design.md b/openspec/changes/fix-cask-sha-stanza-targeting/design.md new file mode 100644 index 0000000..4804df5 --- /dev/null +++ b/openspec/changes/fix-cask-sha-stanza-targeting/design.md @@ -0,0 +1,110 @@ +## Context + +`fix-homebrew-sha-mismatch` extracted Homebrew publishing into a dedicated +`publish-cask` job to close a TOCTOU/skip gap between macOS signing and tap +publication. That restructuring is correct and stays. + +Inside that job, the "Patch cask with actual SHA" step rewrites the +`darwin_arm64` checksum, because signing replaces the darwin archive after +GoReleaser has already computed and templated its SHA. The step's awk +assumed a cask layout that does not hold. + +`openspec/changes/fix-homebrew-sha-mismatch/tasks.md` task 1.5 recorded the +assumption explicitly: + +> the awk pattern assumes the `sha256` directive follows the line containing +> `darwin_arm64` — this structural assumption is validated by the grep step + +Both halves are false. GoReleaser emits `sha256` **before** `url`, and the +grep validated nothing about position. This change supersedes that +assumption rather than editing the historical record of a change that has +already shipped. + +## Goals / Non-Goals + +### Goals +- The `darwin_arm64` stanza carries the SHA of the actual darwin artifact +- No other stanza is modified by the patch step +- A cask layout that violates the step's structural assumption fails the + job loudly instead of producing a silently wrong cask +- Verification is strong enough to reject the exact corruption that shipped + +### Non-Goals +- Repairing the already-published v0.5.0 cask in the tap +- Patching linux checksums (GoReleaser's template values are correct; the + release asset was verified clean) +- Changes to the job graph, permissions, secrets, or `sign-macos` +- Changes to `.goreleaser.yaml` +- darwin_amd64 support (excluded via `ignore:` in `.goreleaser.yaml`) + +## Decisions + +**D1: Require strict generated adjacency.** Match exactly one `url` +directive whose quoted value contains `darwin_arm64`, and require its +immediately preceding line to be a `sha256` directive. Patch only that line. +This avoids carrying a stale checksum candidate across stanza boundaries and +prevents comments or trailing text from changing the target. If GoReleaser +changes the generated shape, fail closed rather than guessing. + +**D2: Fail loudly when the stanza count or target is invalid.** If there is +not exactly one `darwin_arm64` URL stanza, or if no `sha256` line precedes +it, the awk exits non-zero with an `::error::` annotation rather than +emitting the file unchanged. The previous behaviour delegated this case +entirely to the grep, which was not equipped to catch it. Under +`set -euo pipefail` the non-zero exit aborts the step before the patched +file is moved into place; an EXIT trap removes the partial output file. + +**D3: Verify inside exactly one URL stanza, not across the file.** +Verification walks the cask and asserts that exactly one `darwin_arm64` URL +stanza exists and that its preceding `sha256` line carries the computed +SHA. A file-wide `grep` cannot distinguish +"correct" from "written to the wrong stanza" — which is precisely why the +corrupt v0.5.0 cask cleared the gate. This closes the previously unmet +requirement from `fix-homebrew-sha-mismatch`. + +**D4: Compare the SHA as a literal string.** Extract the quoted value from +the structurally validated `sha256` line, normalize its surrounding syntax, +and compare it to the computed SHA using awk string equality. The value is +never interpreted as a regular-expression pattern. + +**D5: Supersede rather than rewrite.** The false assumption in +`fix-homebrew-sha-mismatch/tasks.md` stays as-is. That change is an accurate +record of what was implemented and shipped in v0.5.0; correcting it in place +would erase the provenance of this defect. The spec delta in this change +supersedes it. + +**D6: Cross-check the release manifest before patching.** The archive SHA +computed by `publish-cask` must equal the same archive's entry in the +release's `checksums.txt`. The `sign-macos` job regenerates and re-uploads +that manifest after replacing the signed archive, so it is an independent +integrity check that does not share the cask parser's structural assumption. + +**D7: Extract one tested integrity script.** Put archive hashing, manifest +validation, cask patching, and post-patch verification in +`.github/scripts/patch-homebrew-cask.sh`. The release workflow checks out the +repository and invokes that script instead of embedding untestable awk. +Fixture tests in `.github/scripts/patch-homebrew-cask_test.sh` exercise the +same file CI and release use; the normal `Build and Test` job runs them on +every pull request. + +## Risks / Trade-offs + +**Risk: still structurally coupled to cask layout.** D1 intentionally assumes +GoReleaser's generated `sha256`-then-`url` adjacency. Reordering, inserted +lines, or duplicate darwin URLs fail closed. Fully layout-independent Ruby +parsing is disproportionate for a generated file; strict adjacency is easier +to audit and safer than a permissive heuristic. + +**Risk: verification and patch share a cask-layout assumption.** Both use +the same "exactly one darwin URL with a preceding `sha256`" rule, so the +post-patch verifier is not fully independent of the patcher. It detects the +known wrong-stanza regression and rejects duplicate or comment-only +`darwin_arm64` mentions, but cannot prove arbitrary Ruby structure. D6 +provides an independent check that the SHA itself matches the release +manifest; it does not validate cask placement. + +**Trade-off: repository checkout in `publish-cask`.** The job previously +needed no checkout because all inputs came from release assets. D7 adds a +pinned `actions/checkout` step so the job can execute the same tested script +as CI. The job retains `contents: read`, and the small checkout cost is +accepted in exchange for durable regression coverage. diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md b/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md new file mode 100644 index 0000000..fc90d2b --- /dev/null +++ b/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md @@ -0,0 +1,128 @@ +## Why + +`brew install unbound-force/tap/replicator` is broken at v0.5.0 on **both** +macOS arm64 and Linux amd64. The `publish-cask` job patches the wrong +stanza's `sha256` line in the Homebrew cask. + +GoReleaser emits each cask stanza as `sha256` **then** `url`, so the +`darwin_arm64` marker appears *after* the sha256 line it belongs to. The +current `awk` scans *forward* from the marker for the next `sha256`, which +belongs to the following (`linux_amd64`) stanza. The result is that darwin +keeps its stale pre-signing checksum while linux_amd64 is overwritten with +darwin's. + +Observed in the published v0.5.0 cask, against the release's own +`checksums.txt`: + +| Cask line | Stanza | Published SHA | Actual artifact SHA | +|---|---|---|---| +| 7 | `darwin_arm64` | `ea3278e2…` | `33683ca5…` — stale | +| 14 | `linux_amd64` | `33683ca5…` (darwin's) | `db3f96fc…` — clobbered | +| 18 | `linux_arm64` | `d35cf511…` | `d35cf511…` — correct | + +The `replicator.rb` release asset is clean for both linux stanzas, so the +corruption is introduced by the patch step and exists only in the tap. + +This defect escaped detection because the verification guard is a file-wide +`grep -q "sha256 \"$ARM64_SHA\""`. The darwin SHA *was* present in the file +— on the wrong line — so the grep passed and the corrupt cask shipped. +`fix-homebrew-sha-mismatch` already specified that verification MUST confirm +the SHA is "in the correct context (associated with the `darwin_arm64` +section)"; that requirement was never met by the implementation. + +This is a distinct defect from the one fixed by `fix-homebrew-sha-mismatch` +(issue #81). That change correctly extracted `publish-cask` to close a +TOCTOU/skip gap. The extraction is sound; the awk targeting inside it is not. + +Fixes: https://github.com/unbound-force/replicator/issues/87 + +## What Changes + +Four changes to the release pipeline: + +1. **Independent manifest check.** Confirm the SHA computed from the + downloaded darwin archive matches the darwin entry in the release's + regenerated `checksums.txt` before exposing it to the patch step. +2. **Stanza-targeted patching.** Require exactly one `url` directive whose + quoted value contains `darwin_arm64`, require its immediately preceding + line to be a `sha256` directive, and patch exactly that line. Fail with an + error annotation when the count or adjacency differs. +3. **Context-aware verification.** Replace the file-wide `grep` with a check + that the computed SHA sits inside the `darwin_arm64` stanza, satisfying + the existing (unmet) spec requirement. +4. **Durable regression coverage.** Extract the integrity logic into a + checked-in script used by the release workflow, add fixture-based shell + tests for the shipped corruption and malformed layouts/manifests, and run + those tests in pull-request CI. + +## Capabilities + +### New Capabilities +- `stanza-targeted cask patching`: the patch step resolves exactly one + `darwin_arm64` URL stanza's own `sha256` line and fails loudly when the + cask layout does not match its structural assumption. +- `release-manifest checksum validation`: the downloaded darwin archive's + computed SHA must match the release manifest before cask patching. + +### Modified Capabilities +- `SHA verification before tap push`: verification becomes stanza-aware, so + a SHA written into the wrong stanza is rejected rather than accepted. + +### Removed Capabilities +- None + +## Impact + +- **Files**: `.github/workflows/release.yml` invokes the checked-in patcher; + `.github/workflows/ci.yml` runs its regression tests; `.github/scripts/` + contains the patcher, tests, and cask fixture. No job graph or permission + changes. +- **Users**: Homebrew install works for macOS arm64 and Linux amd64 from the + next release onward. Because the job re-downloads `replicator.rb` from the + release on every run, the next release regenerates the cask from a clean + template and self-heals both platforms. +- **v0.5.0**: remains broken in the tap until a subsequent release or a + manual tap correction. Out of scope for this change. +- **No Go source code changes.** The `Build and Test` job gains one shell + regression step; its existing Go gates are unchanged. + +## Constitution Alignment + +Assessed against the Replicator project constitution +(`.specify/memory/constitution.md`). + +### I. Autonomous Collaboration + +**Assessment**: N/A + +This change modifies the release workflow, adds one test step to CI, and +adds checked-in integrity scripts and fixtures. No MCP tools, tool output +shapes, or inter-agent communication paths are affected. + +### II. Composability First + +**Assessment**: PASS + +Replicator MUST be independently installable. A cask carrying mismatched +checksums breaks the Homebrew distribution channel outright. This change +restores it for both affected platforms, and does so on the signed and +unsigned paths alike, since the patch step is common to both. + +### III. Observable Quality + +**Assessment**: PASS + +The failure mode this change addresses was *silent*: a mispatch produced a +passing gate and a broken install discovered only by end users. Both the +patch and the verification now emit `::error::` annotations and fail the +job at release time, moving detection from the user to the pipeline. + +### IV. Testability + +**Assessment**: PASS + +The integrity logic is a checked-in script with dependency-free fixture +tests executed by pull-request CI. Coverage includes the real v0.5.0 +template, unchanged Linux checksums, missing and duplicate darwin stanzas, +reordered directives, stray comments, and missing, duplicate, or mismatched +manifest entries. diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/specs/release-pipeline.md b/openspec/changes/fix-cask-sha-stanza-targeting/specs/release-pipeline.md new file mode 100644 index 0000000..8c334e9 --- /dev/null +++ b/openspec/changes/fix-cask-sha-stanza-targeting/specs/release-pipeline.md @@ -0,0 +1,151 @@ +## ADDED Requirements + +### Requirement: Stanza-targeted SHA patching + +The `publish-cask` job MUST patch the `sha256` value belonging to the +`darwin_arm64` stanza and MUST NOT modify the `sha256` value of any other +stanza. + +The job MUST require exactly one `url` directive whose quoted URL contains +`darwin_arm64`. Its immediately preceding line MUST be a `sha256` directive, +because the generated cask emits each stanza in that shape. Comments and +other non-URL occurrences of `darwin_arm64` MUST NOT affect target selection. + +#### Scenario: Cask in the expected layout +- **GIVEN** a cask where each stanza emits `sha256` before `url` +- **AND** the `darwin_arm64` stanza carries a stale pre-signing checksum +- **WHEN** the patch step runs with the computed darwin SHA +- **THEN** the `darwin_arm64` stanza's `sha256` MUST be replaced with the + computed SHA +- **AND** the `linux_amd64` and `linux_arm64` stanzas' `sha256` values MUST + be left unchanged + +#### Scenario: No sha256 line precedes a darwin_arm64 reference +- **GIVEN** a cask in which no `sha256` line appears before any + `darwin_arm64` reference +- **WHEN** the patch step runs +- **THEN** the job MUST fail with an `::error::` annotation +- **AND** the cask file MUST NOT be pushed to the Homebrew tap + +#### Scenario: Cask contains no darwin_arm64 stanza +- **GIVEN** a cask with no `darwin_arm64` reference +- **WHEN** the patch step runs +- **THEN** the job MUST fail with an `::error::` annotation rather than + emitting the cask unchanged + +#### Scenario: Cask contains a stray darwin_arm64 comment +- **GIVEN** a cask with exactly one `darwin_arm64` URL stanza +- **AND** a later comment also contains the text `darwin_arm64` +- **WHEN** the patch step runs +- **THEN** the comment MUST NOT affect target selection +- **AND** only the URL stanza's preceding `sha256` MUST be patched + +#### Scenario: Cask contains multiple darwin_arm64 URL stanzas +- **GIVEN** a cask with more than one URL directive containing + `darwin_arm64` +- **WHEN** the patch step runs +- **THEN** the job MUST fail with an `::error::` annotation +- **AND** the cask file MUST NOT be pushed to the Homebrew tap + +#### Scenario: Computed SHA is empty +- **GIVEN** the SHA computation step produced an empty value +- **WHEN** the patch step runs +- **THEN** the job MUST fail with an `::error::` annotation before + attempting to patch + +### Requirement: Release manifest checksum validation + +Before cask patching, the `publish-cask` job MUST confirm that the SHA-256 +computed from the downloaded darwin archive equals the same archive's entry +in the release's regenerated `checksums.txt`. A missing, duplicate, or +mismatched manifest entry MUST fail the job before the SHA is exposed to the +patch step. + +#### Scenario: Downloaded archive matches the release manifest +- **GIVEN** the downloaded darwin archive's computed SHA equals its entry in + `checksums.txt` +- **WHEN** the SHA computation step completes +- **THEN** it MUST expose the validated SHA to the patch step + +#### Scenario: Downloaded archive does not match the release manifest +- **GIVEN** the downloaded darwin archive's computed SHA differs from its + entry in `checksums.txt` +- **WHEN** the SHA computation step runs +- **THEN** the job MUST fail with an `::error::` annotation +- **AND** the cask MUST NOT be patched or pushed + +#### Scenario: Manifest entry is missing or duplicated +- **GIVEN** `checksums.txt` contains zero or more than one entry for the + downloaded darwin archive +- **WHEN** manifest validation runs +- **THEN** the job MUST fail with an `::error::` annotation +- **AND** the cask MUST NOT be patched or pushed + +### Requirement: Automated cask integrity regression tests + +The repository MUST contain dependency-free fixture tests for the exact +integrity script invoked by `publish-cask`, and the `Build and Test` CI job +MUST execute them on pull requests. + +#### Scenario: Pull request changes release integrity logic +- **GIVEN** a pull request changes the cask patcher, its workflow invocation, + or its fixtures +- **WHEN** the `Build and Test` job runs +- **THEN** it MUST execute the cask integrity regression tests + +#### Scenario: Shipped v0.5.0 regression fixture +- **GIVEN** the clean v0.5.0 GoReleaser cask template and an archive whose + manifest checksum matches +- **WHEN** the integrity script runs +- **THEN** it MUST update only the darwin checksum +- **AND** both Linux checksums MUST remain byte-for-byte unchanged + +#### Scenario: Malformed cask or manifest fixture +- **GIVEN** a missing or duplicate darwin URL, reordered sha256 and URL + directives, a stray darwin comment, or a missing, duplicate, or mismatched + manifest entry +- **WHEN** the regression suite runs +- **THEN** each valid layout MUST produce the expected cask +- **AND** each invalid layout or manifest MUST fail closed without modifying + the original cask + +## MODIFIED Requirements + +### Requirement: SHA verification before tap push + +Previously: the `publish-cask` job verified that the patched cask file +contained the computed SHA, using a file-wide search. That search accepted a +cask in which the SHA had been written to the wrong stanza. + +The `publish-cask` job MUST verify that the computed SHA is carried by the +`sha256` line associated with the `darwin_arm64` stanza. A match anywhere +else in the file MUST NOT satisfy this requirement. If verification fails, +the job MUST fail with an `::error::` annotation and MUST NOT push to the +Homebrew tap. + +The verification MUST compare the SHA as a literal string, not as a regular +expression pattern. + +#### Scenario: SHA patched into the darwin_arm64 stanza +- **GIVEN** the cask has been patched and the `darwin_arm64` stanza carries + the computed SHA +- **WHEN** the verification step runs +- **THEN** verification MUST succeed AND the push to the tap MUST proceed + +#### Scenario: SHA present but in the wrong stanza +- **GIVEN** the computed SHA appears in the cask on the `linux_amd64` + stanza's `sha256` line +- **AND** the `darwin_arm64` stanza carries a different value +- **WHEN** the verification step runs +- **THEN** verification MUST fail with an `::error::` annotation +- **AND** the push to the tap MUST NOT proceed + +#### Scenario: SHA absent from the cask +- **GIVEN** the computed SHA does not appear anywhere in the patched cask +- **WHEN** the verification step runs +- **THEN** verification MUST fail with an `::error::` annotation +- **AND** the push to the tap MUST NOT proceed + +## REMOVED Requirements + +None. diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md b/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md new file mode 100644 index 0000000..37b4a07 --- /dev/null +++ b/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md @@ -0,0 +1,64 @@ + + +## 1. Implement tested cask integrity patching + +- [x] 1.1 Add `.github/scripts/patch-homebrew-cask.sh` to compute the + downloaded archive SHA, require exactly one matching release-manifest + entry, patch exactly one darwin URL stanza's immediately preceding + `sha256`, and verify the result. + +- [x] 1.2 Make every invalid input fail closed with an `::error::` + annotation, preserve the original cask on failure, and remove temporary + output on every exit path. + +- [x] 1.3 Update `publish-cask` to check out the repository with the existing + verified, SHA-pinned `actions/checkout` reference, download the archive, + manifest, and cask, then invoke the checked-in script. + +## 2. Add automated regression coverage + +- [x] 2.1 Add the clean v0.5.0 GoReleaser cask template as a test fixture. + +- [x] 2.2 Add dependency-free shell tests covering: successful patch with + both Linux checksums unchanged; missing darwin URL; URL-before-sha256; + stray and trailing darwin comments; duplicate darwin URL; a stale checksum + candidate from another stanza; missing, duplicate, and mismatched manifest + entries; and preservation of the original cask on every invalid case. + +- [x] 2.3 Add the shell regression suite to the `Build and Test` CI job. + +## 3. Documentation + +- [x] 3.1 [P] Update `CHANGELOG.md`: correct the Unreleased entry that + claims the Homebrew SHA mismatch is fixed (it shipped broken in v0.5.0) + and add an entry for the stanza-targeting fix. + +## 4. Verification + +- [x] 4.1 Run the checked-in cask integrity regression suite. + +- [x] 4.2 Reproduce the v0.5.0 wrong-stanza corruption with the old algorithm + and confirm the checked-in regression suite requires both Linux checksums + to remain unchanged. + +- [x] 4.3 Run `actionlint` with ShellCheck integration over both workflows, + and run `shellcheck` directly over both checked-in integrity scripts. + +- [x] 4.4 Run `make check` and `make check-coverage` to confirm no incidental + Go breakage. + +- [x] 4.5 Verify constitution alignment: Composability First (Homebrew + install works on both affected platforms from the next release), + Observable Quality (mispatches fail in PR CI and at release time), and + Testability (the exact production script has dependency-free fixture + coverage executed by CI).