Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/scripts/patch-homebrew-cask.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail

error() {
echo "::error::$*" >&2
exit 1
}

if [ "$#" -ne 3 ]; then
error "Usage: $0 <darwin-archive> <checksums.txt> <cask-file>"
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"
113 changes: 113 additions & 0 deletions .github/scripts/patch-homebrew-cask_test.sh
Original file line number Diff line number Diff line change
@@ -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"
41 changes: 41 additions & 0 deletions .github/scripts/testdata/replicator-v0.5.0.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 13 additions & 35 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand All @@ -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 }}

Expand All @@ -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: |
Expand Down
19 changes: 15 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: unbound-force
created: 2026-08-22
Loading