Skip to content

fix(release): brew tap out of the release critical path so SLSA provenance ships (closes #1763, #1760) - #1767

Merged
asheshgoplani merged 1 commit into
mainfrom
fix/release-tap-nonblocking-attestation
Jul 26, 2026
Merged

fix(release): brew tap out of the release critical path so SLSA provenance ships (closes #1763, #1760)#1767
asheshgoplani merged 1 commit into
mainfrom
fix/release-tap-nonblocking-attestation

Conversation

@asheshgoplani

Copy link
Copy Markdown
Owner

Closes #1763
Closes #1760
Refs #1761 (documented decision — see "The deprecated brews config" below; needs a human call before that issue is closed)

Completes the draft-first release work from #1762: the Homebrew tap comes out of the release's critical path, and SLSA build provenance ships again.

The bug, precisely

goreleaser release pushes the brew formula from inside the release run, and a tap failure fails the whole invocation. The tap token has been returning 401 Bad credentials, so every release died at the GoReleaser step — and everything after it was skipped:

From the v1.10.11 run (30216679269):

      • release published    url=https://github.com/asheshgoplani/agent-deck/releases/tag/v1.10.11
    • homebrew formula
      • error checking for default branch  projectID=asheshgoplani/homebrew-tap statusCode=401 error=GET https://api.github.com/repos/asheshgoplani/homebrew-tap: 401 Bad credentials []
  ⨯ release failed after 2m22s
    error= * homebrew formula: could not get default branch: ... 401 Bad credentials

Step outcomes for that same run:

Step Outcome
Run GoReleaser failure
Validate release assets skipped
Download and verify release artifacts for attestation skipped
Attest build provenance skipped

So the attestation steps were never disabled — they were unreachable. A best-effort package manager was able to cancel build provenance for three consecutive releases (v1.10.9, v1.10.10, v1.10.11) while SECURITY.md tells users to run gh attestation verify.

The fix

1. .goreleaser.ymlbrews.skip_upload: true. GoReleaser now only generates dist/homebrew/Formula/agent-deck.rb and never talks to the tap, so a dead tap token cannot fail goreleaser release. The repository.token template is removed too: with skip_upload there is nothing to authenticate, which also means make release-local no longer requires the tap secret and release-snapshot.yml no longer needs its HOMEBREW_TAP_GITHUB_TOKEN: snapshot-placeholder workaround (that placeholder existed only to keep {{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }} resolvable).

2. release.yml — provenance is signed BEFORE publishing. Attest build provenance moved above Publish release. Beyond un-skipping it, this makes provenance a precondition of visibility: if signing fails, the draft is simply never published and no user is handed an artifact that gh attestation verify cannot check. The attest step only reads local dist-attest/ files, so it does not need the release to be public. If Sigstore is genuinely down and a release must go out anyway, the verified draft can be published by hand (documented in the step comment).

3. release.yml — new final Update Homebrew tap formula (best effort) step, continue-on-error: true, running after Publish release (required for correctness: the formula's download URLs only resolve once the release is public, and homebrew-verify.yml compares the tap against /releases/latest).

It cannot fail the job, but it is not silent either. Every non-success path emits a ::warning:: annotation and an actionable job summary (how to rotate the token, how to push by hand). And it refuses to push a bad formula:

  • the generated formula's version must equal the tag;
  • all four of its sha256 values must match the release's already-verified checksums.txt, paired url-by-url — a formula with a stale checksum breaks brew install for every user with a checksum error, which is worse than a stale formula;
  • the content is base64-encoded into a variable and checked non-empty first, so a broken encode can never overwrite the tap's formula with an empty file;
  • after the push it re-reads the formula through the API (not raw.githubusercontent.com, which is CDN-cached for minutes) and fails if the version did not change — the same "a silent no-op must not pass as success" paranoia as Publish release.

4. Docs. .github/workflows/README.md gains a section on the new tail ordering. SECURITY.md states the known gap honestly: v1.10.9/10/11 have no attestation, that is a broken pipeline rather than a tampered artifact, and a hand checksum check is not a signature. Makefile drops the now-unnecessary HOMEBREW_TAP_GITHUB_TOKEN pre-flight and says plainly that a local release does not attest and does not touch the tap.

The deprecated brews config (#1761) — decision, not an oversight

GoReleaser soft-deprecated brews in v2.10 and hard-deprecated it in v2.16 in favour of homebrew_casks. This PR deliberately stays on brews, for four reasons:

  1. Casks are macOS-only. agent-deck ships linux/amd64 + linux/arm64, and README.md, install.sh and homebrew-verify.yml all advertise brew install asheshgoplani/tap/agent-deck to Mac and Linux/WSL users (Missing homebrew formula #873). A cask silently drops every Linuxbrew user.
  2. It is a user-visible, cross-repository migration. Per the GoReleaser docs the correct path needs a tap_migrations.json plus deleting Formula/agent-deck.rb in asheshgoplani/homebrew-tap, so existing installs transition instead of breaking. None of that can be reviewed or reverted from this repo, and scripts/verify-homebrew-install.sh is hard-wired to Formula/agent-deck.rb.
  3. It cannot be exercised right now. The tap token returns 401, so no tap write of any shape can be validated until it is rotated.
  4. It would make the next release exercise two unproven things at once — the new push path and a new tap layout. The point of release: brews skip_upload + post-publish tap push (completes #1762, fixes #1760 attestation gate) #1763 is to get the tap out of the release critical path; prove that first, migrate second.

The deadline risk is bounded and stated in the GoReleaser docs: "Deprecated options are only removed on major versions of GoReleaser", and release.yml constrains the action to ~> v2. Confirmed empirically: the v1.10.11 run used goreleaser 2.17.1 and brews still only warns. So brews cannot turn into a hard error without an explicit major bump in this repo. Until then the deprecated block does nothing but render a file locally — the least exposed way to use it. The full rationale and a migration checklist are recorded as a comment above the brews block so the next person does not have to rediscover it.

#1761 therefore needs a human call. If you accept the reasoning, close it as decided; if you want the cask migration, it should be its own PR after the tap token is rotated and this pipeline change has survived one real release.

Maintainer action still required

HOMEBREW_TAP_GITHUB_TOKEN is still dead. After this PR the tap step will show as red-but-ignored on every release until it is rotated — visible, non-blocking, and no longer able to cost the release its provenance. To fix at the root: create a fine-grained PAT with Contents: read/write on asheshgoplani/homebrew-tap and set it as the HOMEBREW_TAP_GITHUB_TOKEN repo secret. (The tap formula is currently at 1.10.11 and correct — it was pushed by hand.)

Verification

No Go code changed. The true test is the next real release; everything testable ahead of that was tested.

  • actionlint clean on release.yml, release-snapshot.yml, homebrew-verify.yml.
  • goreleaser check validates .goreleaser.yml (only the expected brews deprecation notice).
  • goreleaser release --snapshot --clean end-to-end locally: exit 0, all four targets cross-compiled, and with skip_upload the formula is still written — writing formula=dist/homebrew/Formula/agent-deck.rb. This is what confirms the path the new step reads, and that no tap credentials are needed any more.
  • The formula-vs-checksums cross-check was run against real GoReleaser output: the locally generated formula against its own dist/checksums.txt (4/4 match), and the live v1.10.11 tap formula (goreleaser 2.17.1 output) against the published v1.10.11 checksums.txt (4/4 match, version parsed as 1.10.11).
  • The tap step's script was extracted from the workflow and driven through 9 scenarios with stubbed gh: happy path (exit 0, tap updated) plus missing token, no formula in dist/, formula/tag version mismatch, checksum mismatch, missing checksums.txt, tap 401, only 3 url/sha256 pairs, and a "push returned success but the tap still shows the old version" no-op. All eight failure paths exit non-zero with the right message and a job summary; none of them can fail the job.
  • bash -n on the step script; make -n release-local parses.

Coordination

Touches no Go files and nothing under internal/ui, so there is no overlap with the in-flight TUI perf work (#1764, #1765). One adjacency: #1687 also edits release-snapshot.yml, but in the on.push.paths list (~line 21) while this PR edits the goreleaser step's env: (~line 49) — different hunks, no conflict expected.

…rovenance ships

goreleaser pushes the homebrew formula from inside `goreleaser release`, so a
tap failure fails the whole invocation. With HOMEBREW_TAP_GITHUB_TOKEN
returning 401 Bad credentials, every release died at the GoReleaser step and
every step after it was skipped — including "Attest build provenance". v1.10.9,
v1.10.10 and v1.10.11 therefore shipped with no SLSA provenance while
SECURITY.md tells users to run `gh attestation verify`. The attestation steps
were never disabled; they were unreachable.

- .goreleaser.yml: brews.skip_upload: true, so goreleaser only generates
  dist/homebrew/Formula/agent-deck.rb and never talks to the tap. Drops the
  repository.token template with it, which also frees `make release-local` and
  release-snapshot.yml from needing the tap secret at all.
- release.yml: sign provenance BEFORE publishing, so an unattested release is
  never made visible (the attest step only reads local files, so it does not
  need a public release).
- release.yml: new final "Update Homebrew tap formula" step with
  continue-on-error, after publish (the formula's URLs only resolve once the
  release is public). It refuses to push unless the formula's version matches
  the tag and all four sha256 values match the verified checksums.txt, guards
  against an empty encode, and re-reads the tap through the API afterwards so a
  silent no-op cannot pass as success. Failures emit a ::warning:: plus a job
  summary with recovery steps instead of failing the release.
- Docs: workflows README on the new tail ordering, SECURITY.md states the
  v1.10.9/10/11 attestation gap honestly, Makefile drops the stale tap-token
  pre-flight.

Stays on the deprecated `brews` config on purpose: casks are macOS-only and
would drop Linuxbrew users, the migration needs tap-side changes that cannot be
validated while the token is 401, and goreleaser removes deprecated options only
on majors while the action is pinned to `~> v2`. Rationale and a migration
checklist are recorded above the brews block.

Closes #1763
Closes #1760
Refs #1761

Committed by Ashesh Goplani
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@asheshgoplani, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e6e7665-3a04-43ce-8d94-47468cf4d655

📥 Commits

Reviewing files that changed from the base of the PR and between 672f8a2 and 1cab5be.

⛔ Files ignored due to path filters (3)
  • .github/workflows/README.md is excluded by !**/*.md
  • CHANGELOG.md is excluded by !**/*.md, !CHANGELOG.md
  • SECURITY.md is excluded by !**/*.md
📒 Files selected for processing (4)
  • .github/workflows/release-snapshot.yml
  • .github/workflows/release.yml
  • .goreleaser.yml
  • Makefile
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/release-tap-nonblocking-attestation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

👋 Thanks for opening this — a couple of intake fields would help the maintainer's validation pipeline (and any reviewer) understand it faster. Nothing here blocks you and nothing is closing: this is a heads-up, and editing the description re-runs this check automatically.

  • Missing required sections: ## What problem does this solve?, ## Why this change, ## User impact, ## AI disclosure, ## What actually bothered you, ## Checklist.
  • No AI-disclosure box is checked — pick exactly one (Human-written / AI-assisted / AI-authored). AI is welcome here with equal standing; this is a signal, not a filter.
  • ## What actually bothered you is empty — one real sentence of human intent is the one field intake cannot accept blank. If an agent opened this PR, quote what the human asked for.

The full field-level contract is in .github/INTAKE.md. AI-authored PRs are welcome here with equal standing.

@asheshgoplani
asheshgoplani merged commit e7cfa84 into main Jul 26, 2026
12 checks passed
@asheshgoplani
asheshgoplani deleted the fix/release-tap-nonblocking-attestation branch July 26, 2026 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant