build: the release workflow, and per-platform install instructions - #177
Merged
Merged
Conversation
.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.
The Install section offered "from source" and a Homebrew heading reading "TBD". Now that the cask is real (#1), it can say what each platform should actually do -- and they are different answers. macOS is Homebrew. The tap needs an explicit URL, since this repository is its own tap and is not named homebrew-markfluence, which is what `brew tap` would otherwise go looking for. That is the one non-obvious step and it now appears where somebody installing will read it, rather than only in a YAML comment. Linux is a release archive, with the checksum verification alongside it rather than as an afterthought. Homebrew is deliberately not offered there: it runs on Linux, but it distributes this binary as a *cask*, and Cask is a macOS feature. The README says so in a sentence, because "why is there no brew line here" is otherwise a reasonable question to waste time on. That also settles the open question in .goreleaser.yaml, by not asking it. It recorded the Linux cask as unverified; no instruction depends on it now, so the comment says the cask is the macOS path and the on_linux blocks goreleaser emits are harmless and unused. `go install` is added to the source section, which is the honest third answer for anyone on neither platform or without either package manager. Refs #1, #175.
release.yml exists now, so the admonition saying it does not is wrong. It becomes a NOTE that no release has been cut yet, which is the part still worth warning about: nothing here has run end to end, and the first attempt should be expected to turn something up. Deleting that note is part of cutting v0.1.0. The two places the draft changes what a reader should expect: the watch step now says the release is created as a draft, uploaded into, and published last, with why; and the failure section no longer describes recovering from a published release with 404ing archives, because that is the state the draft exists to prevent. What you get instead is a draft nobody downstream sees. It also now says the tag must be deleted as well as the draft -- re-pushing an existing tag does nothing, so the workflow would not re-run and the fix would look like it had not worked. Refs #175.
The supported-versions section said markfluence had not had a release yet, which stops being true as soon as v0.1.0 is cut. States the actual pre-1.0 policy rather than deferring: only the latest release is supported, fixes land on main, and there are no backports or patch releases for older lines -- the answer to being on an old version is to upgrade. A reporter deserves to know that before they file, not after. The version table itself is still #95, and still waits for there to be more than one line to put in it. Refs #175, #95.
`release.prerelease` defaults to false, not auto. So a v0.1.0-rc.1 tag -- which docs/releasing.md blesses as the way to rehearse a release -- produced a NON-prerelease draft, and release.yml publishes every draft unconditionally. The release candidate would have become /releases/latest: the exact outcome the draft mechanism was added to prevent, reached from the other direction. It also falsified a sentence already in the runbook, which said goreleaser marks the release a prerelease on its own. It does not, unless told to. Easy to miss because the *other* half of that sentence was true: the Homebrew cask really is skipped for an RC, since skip_upload: "auto" reads the parsed semver rather than this setting. So the visible half of the rehearsal behaved correctly while the release itself did not. Found by code review of the release workflow, and verified against goreleaser's documented default rather than assumed. Refs #175.
Three faults in a block whose whole purpose is to be copied verbatim. `tar -xzf` on the archive extracted everything into the working directory, and the archive is flat: LICENSE, README.md, completions/, markfluence. Run in a home directory or a project checkout -- which is exactly where someone installing a CLI is standing -- it silently overwrote their own README.md and LICENSE. It now extracts only the binary, and says how to get the completions separately. The commands ran unchained, so a failing `sha256sum --check` printed FAILED and the install proceeded anyway. The verification was advisory against the one failure it exists to catch. Now chained with &&. And `install -m 0755 markfluence ~/.local/bin/` fails outright when ~/.local/bin does not exist, which is the common case on a fresh machine. `install -D` creates it. Also: `go install` applies no ldflags, so a binary from that path reports its version as `dev`. Said so, since this README is where that path was newly advertised and a bug report from it cannot otherwise name a version. And a NOTE at the top of Install, because no release has been cut yet: the brew and archive instructions describe something that does not exist to fetch until v0.1.0 ships. It comes out with the one in docs/releasing.md. Refs #175.
Step 6 said to download an archive and check the stamp, then gave `markfluence --version` as the command -- which runs whatever is on $PATH. For a maintainer that is the `make install` build, stamped `dev`. Nothing was downloaded and nothing about the shipped artifact was checked, so the step passed trivially while the released binary could have been stamped wrong, which is the single thing it exists to catch. It now downloads the real archive into a temp directory, verifies the checksum, extracts only the binary, and runs `./markfluence --version` -- with a note saying why the bare command is not the test. The recovery block also now deletes the goreleaser/cask-<tag> branch. goreleaser pushes it after creating the release, so a run that died at the publish step has already left one behind; re-tagging the same version commits a second cask on top of the stale one and the eventual PR carries both. Refs #175.
Claude sometimes elaborates on why and why not and a lot of that doesn't need to exist in instructions. It's easier when they're concise and focused.
willkg
force-pushed
the
release-workflow
branch
from
September 20, 2026 21:46
5ddcd5c to
49e5447
Compare
This was referenced Sep 21, 2026
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.
The code half of #175.
.goreleaser.yamlhas been fully configured since the start and never once invoked; this is the thing that invokes it, plus the install instructions and the policy statements that a first release makes true. Cuttingv0.1.0itself is still a manual step and is not in this PR.No Go code changes.
make check,goreleaser checkand zizmor are all green..github/workflows/release.ymlTag-triggered,
make checkagainst the tagged commit, then goreleaser, then publish. Four decisions in it are not guessable from the config:Triggered on
v*.*.*, notv*. The obvious glob also matches the moving major tag #29 needs, so every release would re-enter the workflow and the second run would fail on a tag goreleaser refuses as non-semver. Actions tag filters are globs rather than regex, so three components is the available way to say it.make checkruns against the tag. 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 a branch that resembles it.The release is created as a draft and published last. This closes a hole the #176 review found: goreleaser creates the GitHub Release before uploading assets, and
/releases/latestis the most recent published non-prerelease whether or not its assets are complete. So a run that died mid-upload would become the releaselatestresolves to — andlatestis what the README sends Linux users to and what #29's action defaults to. Publishing last means a failure leaves a draft nobody downstream sees.contents: writeonly, deliberately notpull-requests: write. goreleaser pushes the cask branch and stops; a human opens the PR. The reason is measured and lives indocs/releasing.md: a PR opened with this token would never triggerci, andmain's ruleset requires that check with nobody able to bypass it.zizmor reports nothing on the file. Every action pinned by commit SHA, no input interpolated into a
run:block,persist-credentials: falsesince 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: falsehas to be explicit, becausesetup-gocaches by default and merely droppingcache: trueleft the finding standing.(zizmor also reports
artipackedagainst the pre-existingci.yml. Left alone as out of scope.)prerelease: auto, which is not the defaultWorth calling out separately because it inverted the draft mechanism.
release.prereleasedefaults tofalse, so av0.1.0-rc.1tag — whichdocs/releasing.mdblesses as the way to rehearse a release — produced a non-prerelease draft, which the publish step then published unconditionally. The release candidate would have become/releases/latest: exactly the outcome the draft was added to prevent, reached from the other direction.Easy to miss because the visible half behaved correctly: the Homebrew cask really is skipped for an RC, since
skip_upload: "auto"reads the parsed semver rather than this setting. So a rehearsal would have looked right while publishing a live release. It also falsified a sentence already in the runbook claiming goreleaser marks prereleases on its own.Install instructions, per platform
The Install section offered "from source" and a Homebrew heading reading
TBD. Now that the cask is real (#1) it can say what each platform should do, and the answers differ.macOS is Homebrew, with the explicit-URL tap — the one non-obvious step, since this repo is its own tap and is not named
homebrew-markfluence. That now appears where somebody installing will read it rather than only in a YAML comment.Linux is a release archive. Homebrew is deliberately not offered there: it runs on Linux, but it distributes this binary as a cask, and Cask is a macOS feature. That also settles the "does the cask work on Linux" question that
.goreleaser.yamlhad recorded as unverified — by not asking it. No instruction depends on it now.The Linux snippet was verified by running it against real goreleaser-built archives rather than by reading it, which caught three faults in a block whose whole purpose is to be pasted verbatim:
tar -xzfextracted everything into the working directory, and the archive is flat —LICENSE,README.md,completions/,markfluence. Run in a home directory or a project checkout, which is exactly where someone installing a CLI is standing, it silently overwrote their ownREADME.mdandLICENSE. Tested with decoy files; it now extracts only the binary and they survive.sha256sum --checkprintedFAILEDand the install proceeded anyway — the verification was advisory against the one failure it exists to catch.install -m 0755 … ~/.local/bin/fails outright when~/.local/bindoes not exist, which is the common case on a fresh machine.Verified working: the checksum check (correctly ignoring the three archives not downloaded) and the single-member extraction. Not yet verified:
install -Don Linux — it cannot be tested here, because macOS ships BSDinstallwhere-Dmeans something unrelated. GNU coreutilsinstall -Dcreates leading directories, which is what the instruction relies on.docs/releasing.mdandSECURITY.mdThe runbook's admonition drops from IMPORTANT to a NOTE:
release.ymlexists now, so saying it does not is wrong, but nothing has run end to end and the first attempt should be expected to turn something up. The watch step describes the draft flow, and the failure section describes recovering a draft rather than retracting a published release — and now also deletes thegoreleaser/cask-<tag>branch, which a failed run has already pushed, since re-tagging would otherwise stack a second cask on the stale one.Its verify step also verified nothing: it said "download an archive and check the stamp" and then gave
markfluence --version, which runs whatever is on$PATH— for a maintainer themake installbuild stampeddev. It passed no matter what shipped. It now downloads, checksums, extracts and runs./markfluence --version.SECURITY.mdstates the actual pre-1.0 policy rather than saying there has never been a release: latest release only, no backports, upgrade is the answer. The version table itself is still #95.Known and deliberate
v0.1.0ships. It comes out with the one indocs/releasing.md, as part of closing Establish the release process and cut v0.1.0 #175.go installproduces an unstampeddevbuild, since it applies no ldflags. Documented rather than fixed:buildinfo.Versionis read as a package var in three places and adding adebug.ReadBuildInfo().Main.Versionfallback is a separate concern from release infrastructure. Worth its own issue.~/.local/binmay not be onPATHin the session that created it — Debian/Ubuntu and Fedora both add it at login only if it already exists. Not currently mentioned in the README.