Skip to content

build: the repo is its own Homebrew tap, and how to cut a release - #176

Merged
willkg merged 7 commits into
mainfrom
homebrew-self-tap
Sep 20, 2026
Merged

willkg merged 7 commits into
mainfrom
homebrew-self-tap

Conversation

@willkg

@willkg willkg commented Sep 20, 2026

Copy link
Copy Markdown
Member

Settles how markfluence gets a Homebrew cask (#1), writes down how to cut a release (part of #175), and lands the plan for the GitHub Action (#29). No Go code changes; make check is green.

This repository is its own Homebrew tap

#1 assumed a separate mozilla/homebrew-markfluence had to exist before the first release. It does not, and the assumption carried a cost the issue did not name: GitHub Actions' automatic GITHUB_TOKEN is scoped to the repository the workflow runs in, so a cross-repo cask push cannot use it — goreleaser's docs say so outright. That means a fine-grained PAT somebody owns and rotates, for a release nobody has cut yet.

mozilla/mozcloud already solves this by being its own tap, and that pattern transfers here: the cask lands in ./Casks, goreleaser names this repository as the target, and secrets.GITHUB_TOKEN does the write because it is no longer cross-repo. (mozilla/homebrew-mozcloud exists and is completely empty — created and never used.) The cost is the tap incantation, since the repo is not named homebrew-markfluence:

brew tap mozilla/markfluence https://github.com/mozilla/markfluence
brew install markfluence

The release cannot merge the cask itself

The part that needed measuring, and the reason the mozcloud precedent could not be copied wholesale. Two facts combine:

  • main's ruleset requires the ci status check, with bypass_actors: [] and current_user_can_bypass: "never". Nobody can force a merge, repo admins included.
  • GitHub does not start workflow runs for events triggered by the automatic GITHUB_TOKEN, so a PR goreleaser opens never runs ci and can therefore never satisfy the check.

A cask PR opened by the release would sit at "waiting for status to be reported" forever, while a direct push to main is refused by the same ruleset's pull_request rule. Measured rather than deduced: mozilla/mozcloud does open its formula PRs this way and they do merge — PR 127 merged with an empty check list — because its ruleset requires no status check at all. This repo's does. Same config, opposite outcome.

The ruleset only protects the default branch, so pushing goreleaser/cask-<tag> is unaffected. So goreleaser commits the cask to that branch and stops; a person runs gh pr create against it, which is a human-triggered event, runs ci, and merges normally. One command per release, no credential to own, and the ruleset left alone.

Other changes to .goreleaser.yaml

darwin/amd64 is built again. It was in ignore on the grounds that the macos-13 runner is retiring — but once the repo is its own tap the build matrix is the install matrix, and the rendered cask carried no on_intel block at all, so brew install markfluence failed outright on an Intel Mac. Runner coverage and user coverage are different questions over different populations, and the second is much larger.

skip_upload: "auto", so a prerelease tag does not bump the cask. CONTRIBUTING.md blesses v1.2.3-rc.1 as the way to rehearse a release, and without this an RC would reach brew install users.

A postflight quarantine hook. The binary is neither signed nor notarized, so macOS quarantines it on download and the first run dies with "the developer cannot be verified". Without the hook the cask installs cleanly and then does not run, which is the worst version of the failure.

Everything above was verified by rendering rather than by reasoning: goreleaser release --snapshot produces dist/homebrew/Casks/markfluence.rb with the completions wired, on_arm and on_intel under on_macos, and per-platform URLs. goreleaser check validates the config and proves nothing about what it generates — which is exactly how the missing on_intel block survived the first pass.

Documentation

docs/releasing.md is a new maintainer runbook; CONTRIBUTING.md only points at it, since its own audience is contributors and none of this applies to them. It carries an [!IMPORTANT] admonition saying the process is not usable yet, because .github/workflows/release.yml does not exist — #175 builds it, and should remove that admonition when it closes. Four things in it are not guessable from the config: the tag must be vX.Y.Z (Go's proxy only sees v-prefixed semver, and goreleaser refuses anything non-semver), every step says whether it runs on a laptop or in Actions, the cask PR is opened by hand with the measurement above for why, and a failed release needs the tag deleted along with the release, since goreleaser publishes before uploading assets.

CLAUDE.md records Casks/markfluence.rb as the second generated artifact under version control, with the same never-hand-edit rule docs/commands/ already has — and the difference that make check cannot guard it, since it regenerates per tag rather than per commit.

_plans/047_github-action.md is the plan for #29, which depends on #175. Two things in it were measured rather than assumed. A composite action cannot reach a sibling action in its own repository — a relative uses: ./setup resolves against $GITHUB_WORKSPACE, the consumer's checkout — so the two actions share a shell script invoked through $GITHUB_ACTION_PATH. And #29's second comment predicted that #149 would leave the idempotence check standing under --force; it did not, so a glob really does republish everything and narrowing to changed files is load-bearing rather than a nicety.

Not in this PR

#1 assumed a separate mozilla/homebrew-markfluence had to exist before the
first release. It does not, and the assumption carried a cost the issue did
not name: GitHub Actions' automatic GITHUB_TOKEN is scoped to the repository
the workflow runs in, so a cross-repo cask push needs a fine-grained PAT that
somebody has to own and rotate. goreleaser's own docs say the default token
cannot do it.

mozilla/mozcloud already solves this by being its own tap -- a Formula/
directory in the repo, goreleaser naming its own repository as the target, and
secrets.GITHUB_TOKEN doing the write because it is no longer cross-repo. That
pattern transfers here unchanged; only the directory differs, since a cask
lands in Casks/ rather than Formula/.

So: target this repository, land the cask in ./Casks, and open a pull request
rather than pushing to main, which keeps the release from writing to the
default branch and makes a version bump reviewable. The cost is the tap
incantation, because the repo is not named homebrew-markfluence:

    brew tap mozilla/markfluence https://github.com/mozilla/markfluence

Two things measured rather than assumed. `goreleaser release --snapshot` now
generates dist/homebrew/Casks/markfluence.rb with the completions wired and
per-platform URLs, so the config is known to produce a cask rather than
merely to validate. And the binary is neither signed nor notarized, so macOS
quarantines it on download and the first run dies with "the developer cannot
be verified" -- the postflight xattr hook is goreleaser's documented answer
for an unsigned cask, and without it the cask installs and does not run.

Recorded as unverified: whether the cask works under Homebrew on Linux. brew
there has refused casks outright, but this one installs a `binary` artifact,
which Homebrew documents as portable. Nobody has run it on a Linux box.

Refs #1, #175.
Two things this plan needed measured rather than assumed, both of which
changed its shape.

A composite action cannot reach a sibling action in its own repository: a
relative `uses: ./setup` resolves against $GITHUB_WORKSPACE, the consumer's
checkout, so they would get `Can't find 'action.yml'` naming a path in their
own repo. Hardcoding the full owner/repo/path@ref form is worse, since it pins
the inner action to a ref the outer one may not be. So the two actions share a
shell script invoked through $GITHUB_ACTION_PATH, not an action.

And #29's second comment predicted that #149 would leave the idempotence check
standing under --force, so that a glob would publish only what actually
differs. That is not what shipped: --force means always PUT and nothing may
suppress the request, overriding both the moved-page refusal and the
unchanged-body skip. A glob really does republish everything, so narrowing to
changed files is load-bearing rather than a nicety.

Still open and deliberately not guessed at: whether step-level env: on a
`uses:` step reaches a composite action's inner steps. Neither the docs nor
ADR 0549 says, and the runner has a cluster of adjacent bugs. The design does
not depend on the answer -- the token is never an input -- and the integration
workflow measures it.

The release pipeline this depends on is #175, and Homebrew is #1.

Refs #29.
Code review caught that the cask could never reach main, and the reason is
specific to this repository rather than to the pattern.

main's ruleset requires the `ci` status check, with an empty bypass_actors and
current_user_can_bypass = never, so nobody can force a merge. And GitHub does
not start workflow runs for events triggered by the automatic GITHUB_TOKEN, so
a pull request goreleaser opens never runs `ci` and can therefore never satisfy
the check. The two together mean a cask PR sits at "waiting for status to be
reported" forever, while a direct push to main is refused by the same ruleset's
pull_request rule.

Measured rather than deduced. mozilla/mozcloud opens its formula PRs exactly
this way and they do merge -- PR 127 merged with an empty check list -- because
its ruleset requires no status check at all and one human approval. This
repository's ruleset requires the check. Same config, opposite outcome, which
is why the mozcloud precedent could not be copied wholesale.

The ruleset only protects the default branch, so pushing goreleaser/cask-<tag>
is unaffected. So goreleaser now commits the cask to that branch and stops; a
person runs `gh pr create` against it, which is a human-triggered event, runs
`ci`, and merges normally. One command per release, no credential to own, and
the ruleset left as it is.

Also from the review:

skip_upload: "auto" -- a prerelease tag would otherwise bump the cask, and
docs/releasing.md is going to bless v0.1.0-rc.1 as the way to rehearse a
release, so `brew install markfluence` would have handed users a release
candidate.

CLAUDE.md records Casks/markfluence.rb as the second generated artifact under
version control, with the never-hand-edit rule docs/commands/ already has --
and with the difference that `make check` cannot guard it, since it is
regenerated per tag rather than per commit.

And _plans/047 claimed `version: latest` resolves to the last release that
succeeded, which would make a half-failed release harmless for the action.
It does not: goreleaser creates the GitHub Release and then uploads assets,
and /releases/latest is the most recent published non-prerelease whether or
not its assets are complete. Getting that property needs release.draft plus a
publish step after the upload.

Refs #1, #175, #29.
darwin/amd64 was in goreleaser's `ignore` list, and the earlier decision to
leave it there was made about CI *runners* -- macos-13 is retiring, so a
fourth build looked like it bought nothing.

That reasoning does not survive the repo becoming its own Homebrew tap (#1).
The build matrix is now the *install* matrix: the rendered cask had no
on_intel block at all, so `brew install markfluence` failed outright on an
Intel Mac with no artifact for the platform. Runner coverage and user
coverage are different questions over different populations, and the second
is much larger.

Verified by rendering rather than reasoning: `goreleaser release --snapshot`
now builds four targets and the cask carries both on_arm and on_intel under
on_macos. Covering macos-13 for #29's action is a side effect.

Windows stays out, and is a named error in the action's installer rather than
a 404: .zip archives, a .exe suffix, and a shell that is not bash unless
every composite step says so.

Refs #1, #29.
Written for the maintainer, at the end of CONTRIBUTING.md rather than in a
docs/releasing.md of its own -- release steps are maintainer documentation and
CONTRIBUTING.md is where a maintainer already looks. This supersedes #175's
docs/releasing.md checklist item.

It leads with an IMPORTANT admonition saying the process is not usable yet,
because release.yml does not exist: .goreleaser.yaml is fully configured and
has never been invoked. Documenting an intended process is worth doing now --
the constraints were established while designing #29 and #1 and would
otherwise have to be rediscovered -- but a reader must not mistake it for a
path they can follow today.

Four things it records that are not guessable from the config:

The tag must be vX.Y.Z, and for two independent reasons. Go's module proxy
only sees semver tags carrying the v prefix, so a bare 1.2.3 makes
`go install @latest` degrade to a pseudo-version; and goreleaser refuses a
non-semver tag outright. The prefix then gets stripped for artifact names,
which is how v1.2.3 becomes markfluence_1.2.3_darwin_arm64.tar.gz.

The rehearsal step is a snapshot build rather than `goreleaser check`, because
check validates the config and proves nothing about what it produces -- the
distinction that caught a cask with no on_intel block.

The cask pull request is opened by hand, with the measurement for why:
main's ruleset requires the `ci` check with no bypass actors, and GitHub does
not start workflow runs for events triggered by the automatic GITHUB_TOKEN, so
a release-opened PR could never satisfy the check. It says not to "fix" this
with pull_request: {enabled: true}, which yields a PR that looks right and can
never land.

And a failed release needs the tag deleted along with the release, since
goreleaser publishes the release before uploading assets -- so a run that dies
mid-upload leaves /releases/latest pointing at archives that 404.

Refs #175, #1.
The release section did not say where goreleaser actually runs, and the only
hint was the word "locally" in the rehearsal step. That is the first thing a
maintainer needs to know, because goreleaser runs in two places for two
different jobs: the rehearsal on a laptop, publishing nothing, and the real
release in GitHub Actions from a clean checkout of the tag. Every step now
says which machine it is on, with a table up front naming what each
invocation does.

Also adds a step for reading the published release notes and fixing them with
`gh release edit --notes-file`. The fix is cheap enough to do without
hesitation, because nothing downstream reads the release body -- the
archives, checksums.txt, the cask and `go install` are all indifferent to it
-- so bad notes are never a reason to re-cut a release. If it becomes routine,
the answer is the changelog block in .goreleaser.yaml rather than doing it
every time.

Refs #175.
It went into CONTRIBUTING.md on the reasoning that a maintainer already looks
there. That was too weak to carry it: the section opens "For maintainers" and
had grown to 161 of the file's 260 lines, so 62% of a document GitHub shows
to someone opening their first pull request was a runbook none of them can
ever perform.

docs/ is where this repo keeps written documentation, and #175 had named
docs/releasing.md before the detour. CONTRIBUTING.md keeps a one-line pointer
and drops back to 102 lines of contributor-facing content.

Headings move up a level for the document's new top level. The in-page anchor
survives unchanged, since GitHub derives it from the heading text rather than
its depth.

CLAUDE.md records the file alongside the rest of docs/, with why
CONTRIBUTING.md only points at it -- otherwise the next person to write
maintainer documentation faces the same fork and has no reason to pick
differently.

Refs #175.
@willkg
willkg merged commit 836c788 into main Sep 20, 2026
1 check passed
@willkg
willkg deleted the homebrew-self-tap branch September 20, 2026 12:30
willkg added a commit that referenced this pull request Sep 20, 2026
.goreleaser.yaml has been fully configured and never once invoked. This is the
thing that invokes it.

Tag-triggered on v*.*.* rather than v*, because the moving major tag #29 needs
would otherwise re-enter the workflow and fail the second run on a tag
goreleaser refuses as non-semver. Actions tag filters are globs, not regex, so
three components is the available way to say it.

`make check` runs against the tagged commit before anything is built. A tag
can be pushed at any commit, so this is what makes "it passed CI" true of the
thing actually being released rather than of some branch that resembles it.

The release is created as a **draft** and published by a final step, which
closes a hole the code review of #176 found. goreleaser creates the GitHub
Release before it uploads assets, and /releases/latest is the most recent
*published* non-prerelease whether or not its assets are complete -- so a run
that died mid-upload would become the release `latest` resolves to, and
anything pinned there 404s on the archive. #29's action defaults to `latest`.
Publishing last means a failure leaves a draft nobody downstream can see, and
recovery is deleting a draft rather than retracting a release.

Only `contents: write`, deliberately not `pull-requests: write`: goreleaser
pushes the cask branch and stops, because a PR opened with this token would
never trigger `ci`, and main's ruleset requires that check with nobody able to
bypass it. docs/releasing.md carries the measurement.

zizmor is clean on this file. Every action pinned by commit SHA, no input
interpolated into a run: block, persist-credentials off since goreleaser
authenticates over the API rather than git, and the module cache disabled --
a cache is writable from less-trusted contexts and this job builds the
artifacts people install. `cache: false` has to be explicit, because setup-go
caches by default and merely dropping `cache: true` left the finding standing.

zizmor also reports artipacked against the pre-existing ci.yml. Left alone as
out of scope.

Refs #175, #29.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant