diff --git a/.github/scripts/patch-homebrew-cask_test.sh b/.github/scripts/patch-homebrew-cask_test.sh index bd8a7c3..f5bb00a 100755 --- a/.github/scripts/patch-homebrew-cask_test.sh +++ b/.github/scripts/patch-homebrew-cask_test.sh @@ -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" @@ -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" || \ + 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" @@ -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" diff --git a/.github/scripts/testdata/replicator-v0.5.0.rb b/.github/scripts/testdata/replicator-v0.5.0.rb index 5c6923c..1a873ac 100644 --- a/.github/scripts/testdata/replicator-v0.5.0.rb +++ b/.github/scripts/testdata/replicator-v0.5.0.rb @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f8c2f5..183bf8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ./... @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 79034e0..2b9734e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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: | + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a80f0..309472a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/openspec/changes/fix-cask-postflight-steps/design.md b/openspec/changes/fix-cask-postflight-steps/design.md new file mode 100644 index 0000000..1b6eb40 --- /dev/null +++ b/openspec/changes/fix-cask-postflight-steps/design.md @@ -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. diff --git a/openspec/changes/fix-cask-postflight-steps/proposal.md b/openspec/changes/fix-cask-postflight-steps/proposal.md new file mode 100644 index 0000000..74b316a --- /dev/null +++ b/openspec/changes/fix-cask-postflight-steps/proposal.md @@ -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. diff --git a/openspec/changes/fix-cask-postflight-steps/specs/homebrew-cask/spec.md b/openspec/changes/fix-cask-postflight-steps/specs/homebrew-cask/spec.md new file mode 100644 index 0000000..d65497a --- /dev/null +++ b/openspec/changes/fix-cask-postflight-steps/specs/homebrew-cask/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Homebrew postflight steps + +The GoReleaser Homebrew cask configuration MUST emit the `postflight_steps` +DSL. The generated cask MUST use `on_macos do` to remove the quarantine +attribute from the staged `replicator` binary, and MUST preserve Ruby +`#{staged_path}` interpolation. + +#### Scenario: Generated cask uses the current Homebrew DSL +- **GIVEN** the release configuration contains the Homebrew cask definition +- **WHEN** GoReleaser renders a snapshot cask +- **THEN** the cask MUST contain `postflight_steps do` and `on_macos do` +- **AND** it MUST contain `#{staged_path}/replicator` + +#### Scenario: Generated cask reintroduces the legacy stanza +- **GIVEN** a GoReleaser render produces a cask containing `postflight do` +- **WHEN** the cask regression suite runs +- **THEN** the suite MUST fail before the change can merge + +### Requirement: Rendered Homebrew cask validation + +The `Build and Test` job MUST render the Homebrew cask with the pinned +GoReleaser version before publishing a release. The cask regression suite MUST +validate the rendered cask contains `postflight_steps do`, does not contain the +legacy `postflight do` stanza, and preserves `#{staged_path}` Ruby +interpolation. + +The GoReleaser setup action MUST use `install-only` mode so the binary is +available on PATH for subsequent steps. + +#### Scenario: Rendered cask uses the current Homebrew DSL +- **GIVEN** the release configuration contains the Homebrew cask definition +- **WHEN** the `Build and Test` job runs the GoReleaser snapshot render +- **THEN** the regression suite MUST validate the generated cask contains + `postflight_steps do` +- **AND** it MUST validate the generated cask retains `#{staged_path}` + +#### Scenario: Missing rendered cask +- **GIVEN** the GoReleaser snapshot command does not produce the expected cask +- **WHEN** the cask regression suite runs +- **THEN** the suite MUST fail rather than validating only the source + configuration + +#### Scenario: GoReleaser setup action completes +- **GIVEN** the `Build and Test` job installs its pinned GoReleaser version +- **WHEN** the setup action runs +- **THEN** it MUST use `install-only: true` so the binary persists on PATH + for the snapshot-rendering step + +### Requirement: Configuration-level cask validation + +The repository MUST retain dependency-free checks for the GoReleaser +`custom_block` source configuration so accidental DSL regressions fail before +the snapshot-rendering step. + +#### Scenario: custom_block removes the current DSL +- **GIVEN** the GoReleaser `custom_block` is empty or removes + `postflight_steps do` +- **WHEN** the cask regression suite runs +- **THEN** the suite MUST fail diff --git a/openspec/changes/fix-cask-postflight-steps/tasks.md b/openspec/changes/fix-cask-postflight-steps/tasks.md new file mode 100644 index 0000000..ce8fc78 --- /dev/null +++ b/openspec/changes/fix-cask-postflight-steps/tasks.md @@ -0,0 +1,67 @@ + + +## 1. Replace deprecated Homebrew cask stanza + +- [x] 1.1 Remove `hooks.post.install` from `.goreleaser.yaml` and replace + with `custom_block` containing `postflight_steps` DSL using `on_macos do`, + `run`, and `#{staged_path}` Ruby interpolation. + +- [x] 1.2 Add maintenance comment above `custom_block` noting that native + GoReleaser `postflight_steps` support is not yet available, with tracking + reference to goreleaser/goreleaser#6873. + +## 2. Update test fixture + +- [x] 2.1 Update `.github/scripts/testdata/replicator-v0.5.0.rb`: replace + `postflight do` with `postflight_steps do`, `if OS.mac?` with + `on_macos do`, `system_command` with `run`, and verify `#{staged_path}` + is preserved. + +## 3. Add rendered-cask regression test + +- [x] 3.1 Add deterministic test case to + `.github/scripts/patch-homebrew-cask_test.sh` that extracts the + `custom_block` from `.goreleaser.yaml` and verifies: + (a) `postflight_steps do` is present, + (b) legacy `postflight do` is absent, + (c) `#{staged_path}` remains valid Ruby interpolation. + +- [x] 3.2 Add assertion to existing happy-path test case verifying + `#{staged_path}` survives SHA patching. + +## 4. Verification + +- [x] 4.1 Run the Homebrew cask integrity regression suite. + +- [x] 4.2 Run `go vet ./...` and `go test ./... -count=1 -race`. + +- [x] 4.3 Verify constitution alignment: Composability First (Homebrew + install works without deprecation warnings), Observable Quality + (regression test catches reversion), Testability (deterministic, + no network, no goreleaser binary). + +## 5. Validate GoReleaser output + +- [x] 5.1 Add rendered-cask requirements and scenarios to the coverage + strategy delta specification. + +- [x] 5.2 Install a pinned GoReleaser release in `Build and Test`, render a + snapshot cask, and validate its postflight DSL in the regression suite. + +- [x] 5.3 Run OpenSpec validation and the affected cask regression suite. + +## 6. Repair CI GoReleaser setup + +- [x] 6.1 Add a regression assertion that the GoReleaser action uses + `install-only: true`, then configure the action accordingly so the + binary persists on PATH for the snapshot-rendering step.