ADR 0010 - Tag-driven releases, one blessed install
- Status: Accepted
- Date: 2026-07-01
- Tracking: internal pre-Codeberg 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.
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: 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).
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. The 2026-07-01 amendment 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.
Canonical source: docs/adr/0010-release-is-a-tag-and-one-blessed-install.md in the madtea repo.