ADR 0010 — A release is a git tag; one blessed cross-platform install
- Status: Accepted
- Date: 2026-07-01
- Tracking: legacy tracker 1711 (release-mess report); release/install overhaul epic filed on acceptance
Context
Every release left a trail on the default branch: a docs(changelog): backfill [Unreleased] PR, a chore: prepare release PR, and their merge commits (legacy tracker 1711).
Two coupled causes:
- The changelog was hand-maintained.
CHANGELOG.md’s[Unreleased]section was updated per-PR by hand, so the discipline lapsed and a backfill scramble preceded every cut (andrelease.shrefuses on an empty[Unreleased]). release.shcommitted to a protected branch. It bumped.claude-plugin/plugin.json, promoted[Unreleased] → [version], and regenerated docs — all commits, so on a branch-protectedmainthey went through their own release PR.
But the commit-to-main step is unnecessary:
- The binary version is already tag-derived (
-X main.version={{.Version}}via GoReleaser ldflags); no runtime code readsplugin.json’s version. - GoReleaser already generates release-page notes from the commit stream, so
the hand-curated
CHANGELOG.mdduplicated what the tag already produces.
So the only reason a release touched protected main was to hand-edit
CHANGELOG.md and plugin.json — both removable.
The install story compounded the confusion: five overlapping paths —
scripts/install.sh (source build), scripts/install-binary.sh (also a source
build, misleadingly named), go install …@latest (the README’s pick),
download-a-release-binary, and madtea install (self-copy). And madtea install
itself was Linux-only: a hardcoded /usr/local/bin/madtea with sudo cp /
sudo chmod, so it did not work on Windows and hit Gatekeeper on macOS.
Decision
A release is a git tag. Cutting one is a single local command that makes zero commits to madtea’s default branch.
Naming corrected by the 2026-07-01 amendment below (legacy tracker 1771): the single command is
scripts/release.sh— dev-side repo tooling, never a shipped subcommand. The next paragraph’s “madtea release” phrasing predates that correction. The shippedmadtea releasecommand group is the unrelated forge-release CRUD product surface (releases of whatever repo the user is in) and has nothing to do with cutting madtea releases.
madtea release (the streamlined release.sh), on the maintainer’s machine:
preflight (clean tree, GoReleaser, GPG key) → resolve the next tag (pre-v1.0 →
patch bump of the last tag; explicit vX.Y.Z override) → tag HEAD and push →
goreleaser release builds every platform, GPG-signs checksums.txt locally
(the signing key never leaves the maintainer’s machine), publishes the forge
release with notes generated and grouped from conventional commits, and
updates the downstream distribution channels — Homebrew tap, Scoop bucket, and
the caucus-race plugin marketplace — each in its own repository. Nothing is
committed to madtea’s default branch.
The changelog is generated, not hand-maintained
CHANGELOG.md’s [Unreleased] section is retired. Release notes are generated by
GoReleaser from the conventional-commit stream (a changelog: block groups
feat/fix/… and filters merge/chore/test/ci noise) and published to the
release page. The existing CHANGELOG.md is frozen as a historical archive
(through v0.14.6) with a header pointing at the releases page. release-due.sh
keeps its days/commits cadence nudge but drops the [Unreleased]-empty check.
plugin.json version is a downstream label, not the source of truth
The git tag is the single version. The binary derives from it; the caucus-race
marketplace.json entry — which is what Claude Code’s plugin update-detection
reads — is bumped per release. .claude-plugin/plugin.json’s version is a
cosmetic manifest label: nothing runtime-reads it and a lagging value does not
break plugin updates, so it is not bumped per release (that is what keeps the
release zero-commit). It is resynced lazily, if ever.
One blessed install path per platform, one universal fallback
Distribution is package-manager-first and idiomatic:
- macOS/Linux:
brew install <tap>/madtea. Windows:scoop install madtea. GoReleaser generates the formula/manifest into their own tap/bucket repositories on every release, and these channels handle the OS trust prompts (Gatekeeper / SmartScreen) and PATH. - Universal fallback: download the release binary and run
madtea install, now cross-platform — Unix →/usr/local/bin(sudo when not root) or a~/.local/binfallback; Windows →%LOCALAPPDATA%\Programs\madteaon the user PATH,.exe, no sudo/chmod — copying in pure Go and folding in the stale-binary sweep the old scripts did. Raw-download users get a documented one-time unblock. go install …@latestremains for Go developers;madtea updateself-updates from releases.
The two source-build bash installers (scripts/install.sh,
scripts/install-binary.sh) are removed; scripts/setup-dev.sh (dev tooling) is
unaffected.
Consequences
- The multi-PR release dance is gone. No backfill PR, no prep PR, no
[Unreleased]discipline, and no “refuses on empty[Unreleased]” gotcha. Cutting a release leaves exactly a tag in the default branch’s history. - Downstream commits move off the critical repo. The tap-formula,
scoop-manifest, and marketplace bumps are commits, but each in its own
repository — madtea’s default branch stays untouched. This is consistent with
ADR 0007’s fail-closed, per-repo posture: cross-repo mutations are explicit and
scoped, never silent against
main. - Install has one story. Package-manager-first with a single cross-platform
fallback; the five-way confusion and the Linux-only
madtea installare retired. - Trade-off accepted:
plugin.jsonversion can lag the tag. Cosmetic — nothing runtime-reads it and the marketplace entry is authoritative for updates — and it is the price of a truly zero-commit release. - Pre-public scope + deferred follow-ups. The tap/bucket/marketplace live on
the internal Gitea until the public launch, so package-manager installs serve
tailnet users until those repos move to a public sixfold.space Codeberg home (a
launch follow-up, alongside legacy tracker 934 / legacy tracker 962–964).
wingetand code-signing / notarization of the raw binary are deferred (both want a signed installer).
Alternatives considered
- release-please / CI-automated release (a bot-maintained release PR, or auto-release on merge). Rejected: it moves signing into CI — the GPG key would have to live in the runner — whereas the decision is a deliberate, single, local cut with the key never leaving the machine. The generated-changelog benefit is kept without the CI trust cost.
- Keep the hand-curated
CHANGELOG.md, just streamline the mechanics. Rejected: the hand-maintenance is the recurring mess (the backfill scramble). Generation removes the cause, not a symptom; the prose richness is traded for zero maintenance, chosen deliberately. - One
chore(release)commit pushed straight to a protected default branch (a narrow carve-out). Rejected: it keeps an in-repo changelog but needs a standing branch-protection + commit-hook exception to preserve what generation makes unnecessary. - A single universal “download +
madtea install” method with a documented Gatekeeper/SmartScreen unblock, no package managers. Superseded: package managers are more idiomatic and let Homebrew/Scoop handle the trust prompts and PATH, at the cost of a per-OS install command rather than one everywhere.
Amendment (2026-07-01) — the release cut is a dev-side script, never a shipped subcommand
The Decision above names “madtea release (the streamlined release.sh)” as
the single release command, reading as if the maintainer cut could ship inside
the binary. legacy tracker 1771 reconciles this: the maintainer release process stays
scripts/release.sh — dev-side tooling in the repo, never shipped in the
binary. A user installing madtea has no business cutting madtea releases,
so the self-release flow must not be present in the product.
This is distinct from the existing madtea release command group, which is
the agnostic gh-parity forge-release CRUD (create/list/get/edit/delete releases
of whatever repo the user is in). That surface is user-facing product,
correct as-is, and unaffected by this ADR. The two must never be conflated:
“cut a version of madtea and publish it” is maintainer process;
“manage releases on a forge” is the product feature.
Everything else in the Decision stands unchanged; scripts/release.sh is the
implementation of the zero-commit tag-driven cut described above (legacy tracker 1775).
Canonical source: docs/adr/0010-release-is-a-tag-and-one-blessed-install.md in the madtea repo.