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:

  1. 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 (and release.sh refuses on an empty [Unreleased]).
  2. release.sh committed to a protected branch. It bumped .claude-plugin/plugin.json, promoted [Unreleased] → [version], and regenerated docs — all commits, so on a branch-protected main they 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 reads plugin.json’s version.
  • GoReleaser already generates release-page notes from the commit stream, so the hand-curated CHANGELOG.md duplicated 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 shipped madtea release command 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/bin fallback; Windows → %LOCALAPPDATA%\Programs\madtea on 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 …@latest remains for Go developers; madtea update self-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 install are retired.
  • Trade-off accepted: plugin.json version 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). winget and 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.