The problem it exists for
When a release merge request merges, something has to decide which commit gets the tag. The usual candidates are the merge commit, the squash commit or the head the merge request recorded, and under GitLab’s automatic rebase that recorded head can be a commit that never made it onto the branch at all. Nothing fails when that happens… the tag just quietly points somewhere the branch never went.
So I went and counted. Across my own Go modules on 1 August 2026, 9 of 231 recent tags weren’t on their default branch.
How it works
Everything comes from the commits
colophon reads commits and commit messages, and nothing else. It never takes an answer from a merge request, an issue or anything else the forge holds. The version is worked out by a pure function over what has landed since the last tag (no clock, no network, no forge), the changelog is written from the same commits, and when a release merges, the tag goes on the commit found on the target branch itself rather than whatever the merge request says it merged.
That’s why plan works from anywhere. Point it at any checkout, on any forge or none, with no credentials at all, and it tells you what version your commits have earned and which commits decided it. It’s also the easiest way to compare colophon against whatever a project releases with today, because it writes nothing.
The forge is where colophon writes: the release branch and its merge request, the tag, the release, and the odd comment. It just never reads its answers back from there.
The release, in four commands
| Command | What it does |
|---|---|
plan | Works out the next version and the commits that earned it. Writes nothing and needs no credentials. |
propose | Re-cuts the release branch from its target, writes the changelog and opens (or updates) the release merge request. |
publish | Once that request merges, resolves the commit that actually landed on the target branch, tags it and creates the release. Safe to re-run. |
release | Creates the release for a tag that already exists, which is the way back when colophon can’t release itself. |
The version a release carries comes from the changelog at the tagged commit, never from the merge request’s description, so a tag and its version can’t drift apart. The docs explain what “actually landed” means in full.
Trailers: saying what a commit means
Because the commits are the only input, anything you want to tell colophon goes in a commit message, mostly as a trailer in the last paragraph:
| Annotation | What it does |
|---|---|
feat:, fix:, type!: | The Conventional Commits subject decides the bump and the changelog entry. perf and refactor release a patch too. |
BREAKING CHANGE: | Marks the change breaking. Below 1.0 that’s held to a minor bump, because declaring a stable API should be a decision rather than something a footer does to you. |
Release-As: | Sets the version outright, which is how a project promotes itself to 1.0.0. |
Release-Note: | Adds a line of prose to the release notes (a fenced release-note block takes anything longer). |
Updates: | Tells an issue, a merge request or a wiki page what became of the work once the commit lands, such as Updates: #13 shipped or a spec page moving to IMPLEMENTED. |
(A bare Closes #13 at the bottom doesn’t knock out the trailers above it, which it would for git interpret-trailers. colophon reads a little more than git does there, on purpose.)
After the merge: apply and announce
Two more verbs run once the work has landed.
apply runs on every push to the target branch and carries out the Updates: trailers that push brought in, commenting on an issue or merge request, or setting a wiki page’s status. It never decides for itself that something is finished. A record changes because a commit said so, and each comment carries a key naming the commit, so a retried pipeline posts nothing twice.
announce tells people a release happened, through whichever adapters the project’s .colophon.yaml opts into. There are two today. discord posts the release to a channel, and the headline projects in the estate announce on the community Discord that way (a Slack adapter is coming soon). file appends the release to a YAML or JSON file in another repository and commits it. The file adapter is how this site’s changelog gets fed, and it’s how the version at the top of this page stays current. Announcing is the one thing colophon does that isn’t idempotent: a webhook can’t be read back, so a retried Discord announcement goes out twice, and the docs say so rather than pretending otherwise.
In a pipeline
Nobody wires those verbs up by hand. cicd’s colophon component turns them into pipeline jobs, and 118 projects include it. It also adds the parts that only make sense in a pipeline:
- publish runs ahead of propose, as the decision below needs.
- A check on the release merge request fails it if it carries anything but the changelog files colophon writes, because under a fast-forward merge any other commit on that branch would end up tagged.
- A project with binaries gets its release on the tag pipeline instead, carrying links to what cicd’s goreleaser component built.
- After each Go tag, the Go module proxy is asked about it once, so pkg.go.dev and the family checks in the pipeline see the release within a minute.
- Announcements default to this blog’s release feed, and a failed announcement can be re-sent with one button.
Decisions and what they cost
- Build, not fork. releaser-pleaser was releasing 96 of my projects when I started, it had had two human commits in its last sixty, and a fix I’d written for exactly this bug sat upstream unacknowledged. A fork would have meant maintaining two things instead of one. What it cost: I own a release tool now, and the library it stands on is no small thing (go/forge alone is roughly four times the size of the tool it replaced). The reasoning is in spec 0001.
- Sequencing over parity. Parity with releaser-pleaser was never the goal. I own every project that uses it, so where releaser-pleaser’s behaviour was just its answer, colophon was free to pick a different one. What it cost: a release tool’s bugs become permanent tags, so the rollout went cicd first, then the forge family, then everything else, and never as a flag day.
- Forge-independent. It was designed from the start to talk to forges only through go/forge, so GitLab, GitHub, Gitea, Codeberg and Bitbucket all work through their adapters, and a forge it has never heard of can be added as somebody else’s module. What it cost: only GitLab is proven by daily use (it’s what this estate runs on), and I’d rather say that up front than imply otherwise.
- Publish first. Publish runs before propose, as separate jobs. Between a release merging and its tag existing, colophon would happily work out the same release all over again, so publish has to close that window first. They’re separate jobs because propose only sees the new tag if its checkout is fetched after publish pushed it. What it cost: a pipeline rule that’s easy to break by folding the two together, so colophon also refuses to propose a release whose commit is already at the tip.
- Exit 0 means it worked.
applyused to report a failed instruction and still exit zero, and a job went green on a 404 because of it. The first fix on the table was an opt-in flag so nobody’s pipeline would go red on upgrade… and I dropped it for a plain non-zero exit, leaving the colour of the pipeline toallow_failure. What it cost: upgrading turned some green pipelines red, on purpose. The whole story is here.
Proof in use
- 116 of the estate’s 145 active projects release through it (measured on 10 October 2026), and nothing runs releaser-pleaser any more. The rest are the Rust crates, which still use release-plz, and things that never cut a version at all, like the sites and this blog.
- colophon cuts its own releases, so every tag on its repository is one it resolved itself.
- The CLI’s contract is held by a feature suite that runs the real command code against a mocked forge (spec 0020).
- Releases across the estate announce themselves to this site through the
fileadapter, which is where the changelog and the latest-releases list on the home page come from. - The headline projects announce each release on the community Discord through the
discordadapter.
Use it when, and when not to
Use it if your project uses Conventional Commits, releases through a merge or pull request, and you want the tag on the commit that actually merged, whichever forge you’re on. It isn’t tied to a language either: all it asks for is the commit convention and a CHANGELOG.md it maintains for you. On anything other than GitLab, try plan first. It writes nothing, and support there comes from the go/forge adapters rather than from me using it every day. And don’t use it yet if you need a stable CLI. It’s pre-1.0, so pin the version.
Where it’s going
Rust is next. The Rust crates still release through release-plz, and the decision to move them to colophon was made on 5 October (spec 0027). Colophon’s half has landed, and the publishing to crates.io and the semver check that go with it are cicd’s to build (cicd#69).
The story in posts
Last reviewed .