From cb6e5dbe26aa442be1c3b514a26a5fef4b223cd0 Mon Sep 17 00:00:00 2001 From: Matt Peter Date: Sat, 22 Aug 2026 15:54:44 -0400 Subject: [PATCH 1/5] docs(openspec): specify tested cask checksum patching Specify strict darwin stanza targeting, independent release-manifest validation, and fixture-based regression tests executed by pull-request CI. The production integrity logic will live in one checked-in script used by both the release workflow and its tests. Fixes: https://github.com/unbound-force/replicator/issues/87 --- .../.openspec.yaml | 2 + .../fix-cask-sha-stanza-targeting/design.md | 110 +++++++++++++ .../fix-cask-sha-stanza-targeting/proposal.md | 126 +++++++++++++++ .../specs/release-pipeline.md | 151 ++++++++++++++++++ .../fix-cask-sha-stanza-targeting/tasks.md | 63 ++++++++ 5 files changed, 452 insertions(+) create mode 100644 openspec/changes/fix-cask-sha-stanza-targeting/.openspec.yaml create mode 100644 openspec/changes/fix-cask-sha-stanza-targeting/design.md create mode 100644 openspec/changes/fix-cask-sha-stanza-targeting/proposal.md create mode 100644 openspec/changes/fix-cask-sha-stanza-targeting/specs/release-pipeline.md create mode 100644 openspec/changes/fix-cask-sha-stanza-targeting/tasks.md 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..cba44cb --- /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: Use `index()` rather than a regex match for the SHA.** The SHA is +already validated as `^[0-9a-f]{64}$` upstream, so regex metacharacters +cannot appear. `index()` nonetheless removes the possibility of the value +being interpreted as a pattern, at no cost in clarity. + +**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..91a4a68 --- /dev/null +++ b/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md @@ -0,0 +1,126 @@ +## 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.** CI's `Build and Test` job is unaffected. + +## Constitution Alignment + +Assessed against the Replicator project constitution +(`.specify/memory/constitution.md`). + +### I. Autonomous Collaboration + +**Assessment**: N/A + +This change modifies a single CI workflow step. 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..67c710c --- /dev/null +++ b/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md @@ -0,0 +1,63 @@ + + +## 1. Implement tested cask integrity patching + +- [ ] 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. + +- [ ] 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. + +- [ ] 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 + +- [ ] 2.1 Add the clean v0.5.0 GoReleaser cask template as a test fixture. + +- [ ] 2.2 Add dependency-free shell tests covering: successful patch with + both Linux checksums unchanged; missing darwin URL; URL-before-sha256; + stray darwin comment; duplicate darwin URL; missing, duplicate, and + mismatched manifest entries; and preservation of the original cask on + every invalid case. + +- [ ] 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 + +- [ ] 4.1 Run the checked-in cask integrity regression suite. + +- [ ] 4.2 Confirm the new verifier rejects the corrupt v0.5.0 cask that the + previous file-wide `grep` accepted. + +- [ ] 4.3 Run `actionlint` with ShellCheck integration over both workflows + and the checked-in integrity scripts. + +- [ ] 4.4 Run `make check` and `make check-coverage` to confirm no incidental + Go breakage. + +- [ ] 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). From 79a85a9814bad2a378209171e91792b894043142 Mon Sep 17 00:00:00 2001 From: Matt Peter Date: Sat, 22 Aug 2026 15:59:52 -0400 Subject: [PATCH 2/5] fix(release): validate and test Homebrew cask checksums Replace the inline awk with one checked-in integrity script that computes the downloaded darwin archive SHA, requires one matching release-manifest entry, patches exactly one strictly-adjacent darwin stanza, and verifies the result before replacing the cask. Add the v0.5.0 cask template as a regression fixture and exercise malformed stanzas, comments, stale candidates, and manifest failures in pull-request CI. Invalid inputs leave the original cask unchanged. Fixes: https://github.com/unbound-force/replicator/issues/87 --- .github/scripts/patch-homebrew-cask.sh | 75 ++++++++++++ .github/scripts/patch-homebrew-cask_test.sh | 115 ++++++++++++++++++ .github/scripts/testdata/replicator-v0.5.0.rb | 41 +++++++ .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 48 ++------ CHANGELOG.md | 19 ++- .../fix-cask-sha-stanza-targeting/tasks.md | 31 ++--- 7 files changed, 278 insertions(+), 54 deletions(-) create mode 100755 .github/scripts/patch-homebrew-cask.sh create mode 100755 .github/scripts/patch-homebrew-cask_test.sh create mode 100644 .github/scripts/testdata/replicator-v0.5.0.rb diff --git a/.github/scripts/patch-homebrew-cask.sh b/.github/scripts/patch-homebrew-cask.sh new file mode 100755 index 0000000..b317a29 --- /dev/null +++ b/.github/scripts/patch-homebrew-cask.sh @@ -0,0 +1,75 @@ +#!/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++ + if (lines[NR - 1] !~ ("^[[:space:]]*sha256 \"" arm64 "\"[[:space:]]*$")) { + 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..56940e2 --- /dev/null +++ b/.github/scripts/patch-homebrew-cask_test.sh @@ -0,0 +1,115 @@ +#!/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_AMD64_SHA="db3f96fcd316e33fc8b0b4f6805ee07313f5338fa13c46889fc8473d7a5f9852" +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" >/dev/null 2>&1; then + fail "$1: expected failure" + fi + cmp -s "$CASE_DIR/before.rb" "$CASE_DIR/replicator.rb" || \ + fail "$1: original cask changed on failure" +} + +new_case happy +assert_success +grep -q "sha256 \"$ARCHIVE_SHA\"" "$CASE_DIR/replicator.rb" || \ + fail "happy: darwin SHA was not patched" +grep -q "sha256 \"$LINUX_AMD64_SHA\"" "$CASE_DIR/replicator.rb" || \ + fail "happy: linux_amd64 SHA changed" +grep -q "sha256 \"$LINUX_ARM64_SHA\"" "$CASE_DIR/replicator.rb" || \ + fail "happy: linux_arm64 SHA changed" + +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..d718458 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. The patch step now binds the marker + to the nearest preceding `sha256` line, fails loudly when the cask layout + does not match, and verifies the SHA landed inside the `darwin_arm64` + stanza rather than anywhere in the file. + (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/tasks.md b/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md index 67c710c..f46fe50 100644 --- a/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md +++ b/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md @@ -12,30 +12,30 @@ ## 1. Implement tested cask integrity patching -- [ ] 1.1 Add `.github/scripts/patch-homebrew-cask.sh` to compute the +- [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. -- [ ] 1.2 Make every invalid input fail closed with an `::error::` +- [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. -- [ ] 1.3 Update `publish-cask` to check out the repository with the existing +- [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 -- [ ] 2.1 Add the clean v0.5.0 GoReleaser cask template as a test fixture. +- [x] 2.1 Add the clean v0.5.0 GoReleaser cask template as a test fixture. -- [ ] 2.2 Add dependency-free shell tests covering: successful patch with +- [x] 2.2 Add dependency-free shell tests covering: successful patch with both Linux checksums unchanged; missing darwin URL; URL-before-sha256; - stray darwin comment; duplicate darwin URL; missing, duplicate, and - mismatched manifest entries; and preservation of the original cask on - every invalid case. + 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. -- [ ] 2.3 Add the shell regression suite to the `Build and Test` CI job. +- [x] 2.3 Add the shell regression suite to the `Build and Test` CI job. ## 3. Documentation @@ -45,18 +45,19 @@ ## 4. Verification -- [ ] 4.1 Run the checked-in cask integrity regression suite. +- [x] 4.1 Run the checked-in cask integrity regression suite. -- [ ] 4.2 Confirm the new verifier rejects the corrupt v0.5.0 cask that the - previous file-wide `grep` accepted. +- [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. -- [ ] 4.3 Run `actionlint` with ShellCheck integration over both workflows +- [x] 4.3 Run `actionlint` with ShellCheck integration over both workflows and the checked-in integrity scripts. -- [ ] 4.4 Run `make check` and `make check-coverage` to confirm no incidental +- [x] 4.4 Run `make check` and `make check-coverage` to confirm no incidental Go breakage. -- [ ] 4.5 Verify constitution alignment: Composability First (Homebrew +- [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 From 4043c07e67b3f6e36411820653445680627cd2a4 Mon Sep 17 00:00:00 2001 From: Matt Peter Date: Sat, 22 Aug 2026 15:59:52 -0400 Subject: [PATCH 3/5] docs(changelog): correct the v0.5.0 Homebrew fix status Separate the TOCTOU fix from the wrong-stanza checksum defect and record that v0.5.0 remained broken on macOS arm64 and Linux amd64. --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d718458..dddc1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,10 @@ this project adheres to [Semantic Versioning](https://semver.org/). 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. The patch step now binds the marker - to the nearest preceding `sha256` line, fails loudly when the cask layout - does not match, and verifies the SHA landed inside the `darwin_arm64` - stanza rather than anywhere in the file. + 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 From d5db311b8c66f033901fb783421c2ecec5776fe5 Mon Sep 17 00:00:00 2001 From: Matt Peter Date: Sat, 22 Aug 2026 16:04:31 -0400 Subject: [PATCH 4/5] fix(release): make cask verification literal and observable Compare the extracted SHA with awk string equality rather than a dynamic regular expression, matching the specification's literal-comparison MUST. Assert every negative fixture emits an error annotation and compare the successful result against the complete expected cask. Correct the OpenSpec impact and verification descriptions to match the final CI and script surface. --- .github/scripts/patch-homebrew-cask.sh | 9 ++++++++- .github/scripts/patch-homebrew-cask_test.sh | 14 ++++++-------- .../fix-cask-sha-stanza-targeting/proposal.md | 6 ++++-- .../changes/fix-cask-sha-stanza-targeting/tasks.md | 4 ++-- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/scripts/patch-homebrew-cask.sh b/.github/scripts/patch-homebrew-cask.sh index b317a29..f876852 100755 --- a/.github/scripts/patch-homebrew-cask.sh +++ b/.github/scripts/patch-homebrew-cask.sh @@ -62,8 +62,15 @@ if ! awk -v arm64="$ARCHIVE_SHA" ' { lines[NR] = $0 } /^[[:space:]]*url "[^"]*darwin_arm64[^"]*"/ { count++ - if (lines[NR - 1] !~ ("^[[:space:]]*sha256 \"" arm64 "\"[[:space:]]*$")) { + 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 } diff --git a/.github/scripts/patch-homebrew-cask_test.sh b/.github/scripts/patch-homebrew-cask_test.sh index 56940e2..bd8a7c3 100755 --- a/.github/scripts/patch-homebrew-cask_test.sh +++ b/.github/scripts/patch-homebrew-cask_test.sh @@ -5,7 +5,6 @@ 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_AMD64_SHA="db3f96fcd316e33fc8b0b4f6805ee07313f5338fa13c46889fc8473d7a5f9852" LINUX_ARM64_SHA="d35cf51192f4bc3eb92d32c2a63304fdbc561243a2bb8e406d0a5c7f9d1a83f1" fail() { @@ -41,21 +40,20 @@ assert_failure_preserves_cask() { if "$PATCHER" \ "$CASE_DIR/$ARCHIVE_NAME" \ "$CASE_DIR/checksums.txt" \ - "$CASE_DIR/replicator.rb" >/dev/null 2>&1; then + "$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 -grep -q "sha256 \"$ARCHIVE_SHA\"" "$CASE_DIR/replicator.rb" || \ - fail "happy: darwin SHA was not patched" -grep -q "sha256 \"$LINUX_AMD64_SHA\"" "$CASE_DIR/replicator.rb" || \ - fail "happy: linux_amd64 SHA changed" -grep -q "sha256 \"$LINUX_ARM64_SHA\"" "$CASE_DIR/replicator.rb" || \ - fail "happy: linux_arm64 SHA changed" +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" diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md b/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md index 91a4a68..fc90d2b 100644 --- a/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md +++ b/openspec/changes/fix-cask-sha-stanza-targeting/proposal.md @@ -83,7 +83,8 @@ Four changes to the release pipeline: 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.** CI's `Build and Test` job is unaffected. +- **No Go source code changes.** The `Build and Test` job gains one shell + regression step; its existing Go gates are unchanged. ## Constitution Alignment @@ -94,7 +95,8 @@ Assessed against the Replicator project constitution **Assessment**: N/A -This change modifies a single CI workflow step. No MCP tools, tool output +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 diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md b/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md index f46fe50..37b4a07 100644 --- a/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md +++ b/openspec/changes/fix-cask-sha-stanza-targeting/tasks.md @@ -51,8 +51,8 @@ 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 the checked-in integrity scripts. +- [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. From 671436a6e5309b21babf0189cd3d27366f5a206e Mon Sep 17 00:00:00 2001 From: Matt Peter Date: Sat, 22 Aug 2026 16:06:35 -0400 Subject: [PATCH 5/5] docs(openspec): align SHA comparison decision with implementation --- openspec/changes/fix-cask-sha-stanza-targeting/design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openspec/changes/fix-cask-sha-stanza-targeting/design.md b/openspec/changes/fix-cask-sha-stanza-targeting/design.md index cba44cb..4804df5 100644 --- a/openspec/changes/fix-cask-sha-stanza-targeting/design.md +++ b/openspec/changes/fix-cask-sha-stanza-targeting/design.md @@ -62,10 +62,10 @@ SHA. A file-wide `grep` cannot distinguish corrupt v0.5.0 cask cleared the gate. This closes the previously unmet requirement from `fix-homebrew-sha-mismatch`. -**D4: Use `index()` rather than a regex match for the SHA.** The SHA is -already validated as `^[0-9a-f]{64}$` upstream, so regex metacharacters -cannot appear. `index()` nonetheless removes the possibility of the value -being interpreted as a pattern, at no cost in clarity. +**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