Skip to content
Open
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
68 changes: 68 additions & 0 deletions .github/scripts/patch-homebrew-cask_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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"
RENDERED_CASK=${1:-}
ARCHIVE_NAME="replicator_0.5.0_darwin_arm64.tar.gz"
LINUX_ARM64_SHA="d35cf51192f4bc3eb92d32c2a63304fdbc561243a2bb8e406d0a5c7f9d1a83f1"

Expand Down Expand Up @@ -54,6 +55,8 @@ 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"
grep -q '#{staged_path}' "$CASE_DIR/replicator.rb" || \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] code-organization

The grep -q '#{staged_path}' assertion is technically redundant with the cmp -s fixture comparison above it: if the patched cask is byte-identical to the expected fixture, the grep will always pass. This was deliberately added per the spec's coverage strategy (task 3.2) as a self-documenting canary for the #{staged_path} invariant.

fail "happy: patched cask is missing Ruby interpolation #{staged_path}"

new_case stray-comment
printf '\n# note: darwin_arm64 builds are notarized\n' >> "$CASE_DIR/replicator.rb"
Expand Down Expand Up @@ -110,4 +113,69 @@ new_case mismatched-manifest
printf '%064d %s\n' 0 "$ARCHIVE_NAME" > "$CASE_DIR/checksums.txt"
assert_failure_preserves_cask "mismatched manifest entry"

# ---------------------------------------------------------------------------
# Rendered-cask regression: validate the custom_block in .goreleaser.yaml
# emits correct Homebrew postflight_steps DSL without running goreleaser.
# ---------------------------------------------------------------------------
GORELEASER="$SCRIPT_DIR/../../.goreleaser.yaml"
CI_WORKFLOW="$SCRIPT_DIR/../../.github/workflows/ci.yml"
if [ ! -f "$GORELEASER" ]; then
fail "rendered-cask: .goreleaser.yaml not found at $GORELEASER"
fi
if [ ! -f "$CI_WORKFLOW" ]; then
fail "rendered-cask: CI workflow not found at $CI_WORKFLOW"
fi

# goreleaser-action must use install-only mode so the binary is available
# on PATH for subsequent steps (the snapshot render below).
awk '
/uses: goreleaser\/goreleaser-action@/ { in_action = 1; next }
in_action && /install-only: true/ { found = 1 }
in_action && /^[[:space:]]*-[[:space:]]/ { in_action = 0 }
END { exit !found }
' "$CI_WORKFLOW" || fail "rendered-cask: GoReleaser action must set install-only: true"

# Extract the custom_block literal block scalar value from .goreleaser.yaml.
# The block starts on the line after "custom_block: |" and continues while
# lines are indented deeper than the key's column.
CUSTOM_BLOCK=$(awk '
/^[[:space:]]*custom_block:[[:space:]]*\|/ {
# Determine the indentation of the key itself
match($0, /^[[:space:]]*/); key_indent = RLENGTH
capturing = 1
next
}
capturing {
# Lines in the block must be indented more than the key
match($0, /^[[:space:]]*/); line_indent = RLENGTH
if (line_indent > key_indent || $0 ~ /^[[:space:]]*$/) {
print
} else {
exit
}
}
' "$GORELEASER")

assert_current_postflight_dsl() {
local cask=$1
local source=$2

[ -s "$cask" ] || fail "$source: cask is missing or empty"
grep -q 'postflight_steps do' "$cask" || \
fail "$source: cask is missing 'postflight_steps do' (new Homebrew DSL)"
if grep -Eq '^[[:space:]]*postflight[[:space:]]+do' "$cask"; then
fail "$source: cask contains legacy 'postflight do' (should be 'postflight_steps do')"
fi
grep -q '#{staged_path}' "$cask" || \
fail "$source: cask is missing Ruby interpolation '#{staged_path}'"
}

CONFIG_CASK="$WORK/custom-block.rb"
printf '%s\n' "$CUSTOM_BLOCK" > "$CONFIG_CASK"
assert_current_postflight_dsl "$CONFIG_CASK" "goreleaser custom_block"

if [ -n "$RENDERED_CASK" ]; then
assert_current_postflight_dsl "$RENDERED_CASK" "rendered cask"
fi

echo "PASS: Homebrew cask integrity regression suite"
6 changes: 3 additions & 3 deletions .github/scripts/testdata/replicator-v0.5.0.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@

binary "replicator"

postflight do
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/replicator"]
postflight_steps do
on_macos do
run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/replicator"]
end
end

Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false

- name: Vet
run: go vet ./...
Expand All @@ -39,8 +42,18 @@ jobs:
go install golang.org/x/vuln/cmd/govulncheck@3e6f44f962742443c11ae2261f02e0c917aeb2bc # v1.5.0
govulncheck ./...

- name: Install GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
install-only: true
distribution: goreleaser
version: v2.18.2

- name: Render Homebrew cask
run: goreleaser release --snapshot --clean --skip=announce

- name: Test Homebrew cask integrity patching
run: .github/scripts/patch-homebrew-cask_test.sh
run: .github/scripts/patch-homebrew-cask_test.sh dist/homebrew/Casks/replicator.rb

- name: Test
run: go test ./... -count=1 -race -coverprofile=coverage.out
Expand Down
14 changes: 8 additions & 6 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,14 @@ homebrew_casks:
homepage: "https://github.com/unbound-force/replicator"
directory: Casks
skip_upload: true
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/replicator"]
end
# custom_block: native postflight_steps not yet supported by GoReleaser DSL;
# migrate when available (tracking: goreleaser/goreleaser#6873)
custom_block: |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] future-maintenance

custom_block bypasses GoReleaser native DSL validation for cask stanzas. If GoReleaser later introduces a first-class postflight_steps key, the custom_block will need another migration. There is no current GoReleaser release with native postflight_steps support, so this is the correct approach now, but it should be noted as a maintenance touchpoint.

Suggested fix: Add a comment above the custom_block stanza in .goreleaser.yaml (e.g., # custom_block: native postflight_steps not yet supported by GoReleaser DSL; migrate when available) so future maintainers understand the reason for the escape hatch.

postflight_steps do
on_macos do
run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/replicator"]
end
end
repository:
owner: unbound-force
name: homebrew-tap
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ this project adheres to [Semantic Versioning](https://semver.org/).
## Unreleased

### Fixed
- Homebrew cask `postflight` deprecation warning on Homebrew 7.0+. Replaced
the deprecated `postflight do` stanza with the new `postflight_steps`
declarative DSL in the GoReleaser cask configuration via `custom_block`.
Eliminates deprecation warnings during `brew install` and prevents a hard
error after 2027-12-11.
(Fixes [#111](https://github.com/unbound-force/replicator/issues/111))
- 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
Expand Down
88 changes: 88 additions & 0 deletions openspec/changes/fix-cask-postflight-steps/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
## Context

Homebrew 7.0 deprecated the `postflight do ... end` cask stanza in favor
of the declarative `postflight_steps` DSL. The deprecation will become a
hard error after 2027-12-11.

GoReleaser generates the Homebrew cask from `.goreleaser.yaml`. The
`hooks.post.install` key maps to the deprecated `postflight do` block.
GoReleaser does not yet support native `postflight_steps` generation
(tracking: goreleaser/goreleaser#6873), but the `custom_block` key injects
arbitrary Ruby verbatim into the generated cask.

The existing test infrastructure (`patch-homebrew-cask_test.sh`) validates
SHA patching logic and fixture integrity but does not exercise the
goreleaser configuration's cask stanza content. A reversion of the
`custom_block` to legacy `hooks.post.install` would pass all existing
tests.

## Goals / Non-Goals

### Goals
- Replace `hooks.post.install` with `custom_block` containing
`postflight_steps` DSL
- Update the test fixture to match the new DSL
- Add a deterministic regression test that validates the `custom_block`
content against three invariants: `postflight_steps do` present, legacy
`postflight do` absent, `#{staged_path}` Ruby interpolation intact
- All tests pass without network access or goreleaser binary

### Non-Goals
- Native GoReleaser `postflight_steps` support (blocked on upstream)
- Changes to quarantine removal behavior
- Changes to Go source code
- Changes to the job graph, permissions, or secrets

## Decisions

**D1: Use `custom_block` as the migration vehicle.** GoReleaser's
`custom_block` injects content verbatim into the generated cask. This is
the documented escape hatch for DSL features that GoReleaser does not yet
model natively. A comment above the stanza tracks the upstream issue for
future migration when native support ships.

**D2: Validate both the GoReleaser configuration and rendered cask.** The
configuration assertions fail quickly for accidental DSL changes. CI also
renders a snapshot with a pinned GoReleaser version and passes the generated
cask to the regression suite. This verifies that GoReleaser preserves the
custom block when it produces the release artifact.

**D3: Use awk for `custom_block` extraction.** The `custom_block` value in
`.goreleaser.yaml` is a YAML literal block scalar (`|`). Its content starts
on the line after `custom_block: |` and continues while lines are indented
deeper than the `custom_block` key. Awk can reliably extract this without
a YAML parser, avoiding new dependencies. The extraction is validated by
the assertions that follow it.

**D4: Three-invariant assertion set.** The regression test checks:
1. `postflight_steps do` is present (new DSL adopted)
2. `postflight do` without `_steps` is absent (legacy form removed)
3. `#{staged_path}` is present (Ruby interpolation, not Go template
`{{staged_path}}` or literal text)

These three checks cover the complete DSL transition. If any is violated,
the cask will either emit deprecation warnings, fail on Homebrew 7.0+,
or target a nonexistent path at install time.

**D5: Add to existing test file.** The rendered-cask test is added to
`patch-homebrew-cask_test.sh` as a separate section. It validates the
goreleaser config that feeds the patcher's fixture, keeping all cask
integrity tests in one file and one CI step.

## Risks / Trade-offs

**Risk: `custom_block` extraction relies on YAML indentation.** The awk
extractor assumes the literal block scalar follows standard YAML
indentation rules. If `.goreleaser.yaml` is reformatted with non-standard
indentation, the extraction may fail. This is mitigated by the subsequent
assertions: if extraction produces garbage, the assertions fail.

**Risk: GoReleaser changes `custom_block` semantics.** A pinned GoReleaser
version renders the cask in every pull request, so CI fails if its output no
longer contains the required DSL. The tracking comment and upstream issue
reference (goreleaser/goreleaser#6873) flag this for future migration.

**Trade-off: awk over YAML parser.** A YAML parser would be more robust
but would require a new dependency (Python/PyYAML in CI or a Go YAML
library). The awk approach is dependency-free and sufficient for the
literal block scalar pattern used here.
101 changes: 101 additions & 0 deletions openspec/changes/fix-cask-postflight-steps/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## Why

`brew install unbound-force/tap/replicator` emits repeated deprecation
warnings on Homebrew 7.0+:

```
Warning: Calling `postflight` is deprecated! Use `postflight_steps` instead.
```

The `postflight` stanza will become a hard error after 2027-12-11, at which
point the cask will fail to install entirely.

The root cause is `.goreleaser.yaml`: the `hooks.post.install` key emits a
`postflight do` block in the generated cask. Homebrew 7.0 replaced
`postflight` with the declarative `postflight_steps` DSL.

GoReleaser does not yet have native `postflight_steps` support (tracking:
goreleaser/goreleaser#6873). The `custom_block` escape hatch is available
and injects content verbatim into the generated cask.

Fixes: https://github.com/unbound-force/replicator/issues/111

## What Changes

Two changes to the release configuration and one to the test fixture:

1. **Replace deprecated hooks.** Remove `hooks.post.install` from
`.goreleaser.yaml` and replace it with a `custom_block` containing the
`postflight_steps` DSL (`on_macos do`, `run`, `#{staged_path}`).
2. **Update test fixture.** Update
`.github/scripts/testdata/replicator-v0.5.0.rb` to use
`postflight_steps do`, `on_macos do`, `run`, and `#{staged_path}`.
3. **Add rendered-cask regression test.** Add a deterministic test case
that extracts the `custom_block` from `.goreleaser.yaml` and verifies
`postflight_steps do` is present, legacy `postflight do` is absent,
and `#{staged_path}` remains valid Ruby interpolation.

## Capabilities

### New Capabilities
- `postflight_steps cask stanza`: Homebrew cask uses the new declarative
`postflight_steps` DSL instead of the deprecated `postflight` block.
- `rendered-cask regression test`: Deterministic test that validates the
goreleaser configuration emits correct Homebrew syntax without network
access.

### Modified Capabilities
- `quarantine removal`: Unchanged behavior (still removes
`com.apple.quarantine` from the replicator binary on macOS); only the
Homebrew DSL syntax changes.

### Removed Capabilities
- None

## Impact

- **Files**: `.goreleaser.yaml` (replace `hooks.post.install` with
`custom_block`), `.github/scripts/testdata/replicator-v0.5.0.rb`
(update fixture stanza syntax),
`.github/scripts/patch-homebrew-cask_test.sh` (add rendered-cask
regression test).
- **Users**: No functional change to quarantine removal behavior.
Deprecation warnings disappear on Homebrew 7.0+. Future-proofs
against 2027-12-11 hard error.
- **No Go source code changes.** Only release configuration and CI test
infrastructure are affected.

## Constitution Alignment

### I. Autonomous Collaboration

**Assessment**: N/A

No MCP tools, tool output shapes, or inter-agent communication paths are
affected. This change modifies release configuration only.

### II. Composability First

**Assessment**: PASS

Replicator MUST be independently installable via Homebrew. A deprecated
stanza that will become a hard error blocks the distribution channel.
This change restores clean installation on Homebrew 7.0+ and prevents
future breakage.

### III. Observable Quality

**Assessment**: PASS

The regression test deterministically validates the goreleaser
configuration emits correct Homebrew syntax. If the `custom_block` is
reverted to legacy `postflight`, the test catches it in pull-request CI.

### IV. Testability

**Assessment**: PASS

The rendered-cask test extracts the `custom_block` from `.goreleaser.yaml`
at test time, requires no network access, no goreleaser binary, and no
Ruby interpreter. It verifies the DSL transition and Ruby interpolation
syntax deterministically.
Loading
Loading