Skip to content

build: the release workflow, and per-platform install instructions - #177

Merged
willkg merged 8 commits into
mainfrom
release-workflow
Sep 20, 2026
Merged

willkg merged 8 commits into
mainfrom
release-workflow

Conversation

@willkg

@willkg willkg commented Sep 20, 2026

Copy link
Copy Markdown
Member

The code half of #175. .goreleaser.yaml has 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. Cutting v0.1.0 itself is still a manual step and is not in this PR.

No Go code changes. make check, goreleaser check and zizmor are all green.

.github/workflows/release.yml

Tag-triggered, make check against the tagged commit, then goreleaser, then publish. Four decisions in it are not guessable from the config:

Triggered on v*.*.*, not v*. 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 check runs 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/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 latest is 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: write only, deliberately not pull-requests: write. goreleaser pushes the cask branch and stops; a human opens the PR. The reason is measured and lives in docs/releasing.md: a PR opened with this token would never trigger ci, and main'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: false 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.)

prerelease: auto, which is not the default

Worth calling out separately because it inverted the draft mechanism. release.prerelease defaults to false, 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, 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.yaml had 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 -xzf 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. Tested with decoy files; it now extracts only the binary and they survive.
  • 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.
  • install -m 0755 … ~/.local/bin/ fails outright when ~/.local/bin does 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 -D on Linux — it cannot be tested here, because macOS ships BSD install where -D means something unrelated. GNU coreutils install -D creates leading directories, which is what the instruction relies on.

docs/releasing.md and SECURITY.md

The runbook's admonition drops from IMPORTANT to a NOTE: release.yml exists 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 the goreleaser/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 the make install build stamped dev. It passed no matter what shipped. It now downloads, checksums, extracts and runs ./markfluence --version.

SECURITY.md states 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

  • A NOTE at the top of Install says no release exists yet, because the brew and archive instructions describe something there is nothing to fetch for until v0.1.0 ships. It comes out with the one in docs/releasing.md, as part of closing Establish the release process and cut v0.1.0 #175.
  • go install produces an unstamped dev build, since it applies no ldflags. Documented rather than fixed: buildinfo.Version is read as a package var in three places and adding a debug.ReadBuildInfo().Main.Version fallback is a separate concern from release infrastructure. Worth its own issue.
  • ~/.local/bin may not be on PATH in 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.

.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
willkg merged commit 1d0a2d0 into main Sep 20, 2026
1 check passed
@willkg
willkg deleted the release-workflow branch September 20, 2026 21:50
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