build: the repo is its own Homebrew tap, and how to cut a release - #176
Merged
Merged
Conversation
#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.
This was referenced Sep 20, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 checkis green.This repository is its own Homebrew tap
#1 assumed a separate
mozilla/homebrew-markfluencehad to exist before the first release. It does not, and the assumption carried a cost the issue did not name: GitHub Actions' automaticGITHUB_TOKENis 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/mozcloudalready solves this by being its own tap, and that pattern transfers here: the cask lands in./Casks, goreleaser names this repository as the target, andsecrets.GITHUB_TOKENdoes the write because it is no longer cross-repo. (mozilla/homebrew-mozcloudexists and is completely empty — created and never used.) The cost is the tap incantation, since the repo is not namedhomebrew-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 thecistatus check, withbypass_actors: []andcurrent_user_can_bypass: "never". Nobody can force a merge, repo admins included.GITHUB_TOKEN, so a PR goreleaser opens never runsciand 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
mainis refused by the same ruleset'spull_requestrule. Measured rather than deduced:mozilla/mozclouddoes 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 runsgh pr createagainst it, which is a human-triggered event, runsci, and merges normally. One command per release, no credential to own, and the ruleset left alone.Other changes to
.goreleaser.yamldarwin/amd64is built again. It was inignoreon the grounds that themacos-13runner is retiring — but once the repo is its own tap the build matrix is the install matrix, and the rendered cask carried noon_intelblock at all, sobrew install markfluencefailed 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.mdblessesv1.2.3-rc.1as the way to rehearse a release, and without this an RC would reachbrew installusers.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 --snapshotproducesdist/homebrew/Casks/markfluence.rbwith the completions wired,on_armandon_intelunderon_macos, and per-platform URLs.goreleaser checkvalidates the config and proves nothing about what it generates — which is exactly how the missingon_intelblock survived the first pass.Documentation
docs/releasing.mdis a new maintainer runbook;CONTRIBUTING.mdonly 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.ymldoes 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 bevX.Y.Z(Go's proxy only seesv-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.mdrecordsCasks/markfluence.rbas the second generated artifact under version control, with the same never-hand-edit ruledocs/commands/already has — and the difference thatmake checkcannot guard it, since it regenerates per tag rather than per commit._plans/047_github-action.mdis 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 relativeuses: ./setupresolves 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
release.ymlandv0.1.0— Establish the release process and cut v0.1.0 #175.brew installend to end, which both need a real release first — Homebrew: this repository is its own tap #1.brewthere has refused casks outright, but this one installs abinaryartifact, which Homebrew documents as portable. Recorded as unverified in the config comment and on Homebrew: this repository is its own tap #1; nobody has run it on a Linux box.