Featured image of post Nine tags that were never on the branch

Nine tags that were never on the branch

colophon works out the version your commits have earned, opens the release as a merge request, and tags what actually landed. Not what your forge says did.

On the last day of July, go/config v0.12.1 went out without a correctness warning that two of its adapter pages had picked up a few days earlier. The commit was on main. It simply wasn’t in the release.

I only found out because I’d gone looking for something else.

The tag wasn’t on main either. git merge-base --is-ancestor v0.12.1 origin/main came back non-zero, which is git’s way of telling you that the thing you released is not, and never was, on the branch you released it from. So I checked the four tags before it, and two of those weren’t on main either. git log --all --grep="chore(main): release v0.11.0" returned three separate commits carrying that message. Three, for one release!

Both of the losses turned out to be documentation. That is luck and nothing else. A Go module version cannot be re-cut once proxy.golang.org has served it, so had either of those missing commits been a code fix, the fix would have been gone for good and the next version would have been the earliest possible home for it.

I broke it myself, with a checkbox

A release merge request is opened from main at commit A, carrying one release commit, R. Ordinary work carries on meanwhile, and somebody else’s merge request lands as C. Then the release request merges, and a fast-forward merge cannot replay a stale branch as it stands, so it is rebased onto C first, producing R’, and R’ is what main becomes.

main runs along the top, A to C to R’, and every commit on it is exactly where it should be. The release branch underneath it never rejoins. The merge request’s record still reports R as its head, pre-rebase, so R is where the tag gets cut, and R’s parent is A. C is not in it. The tagged tree is missing whatever landed while the release sat waiting, and nothing anywhere in the process is in a position to notice.

Embarrassingly simple, once you’ve seen it.

There’s a reason a release merge request is sitting there long enough to go stale in the first place, and it’s a decision I’d written up a few weeks earlier with all the confidence of a man who has just solved something. I’d moved the estate off tag-on-merge, the semantic-release way where you merge a thing and a release falls out of it, onto a model where the release is itself a merge request the bot keeps up to date and only cuts a tag when you merge it.

A release should stand in the same queue as the smallest bugfix and pass the same gates, and under that model it does. It also killed off the develop branch I’d been carrying, which was long overdue.

I still think all of that is right. What I hadn’t clocked is that the new model has a shape the old one didn’t: a branch that exists for hours at a time, aimed at a target that keeps moving. Tag-on-merge never had one of those, so it had nothing that could go stale. I’d taken releases out of the privileged lane and put them in the queue with everything else, and queues are where things wait.

The rebase is the new part. GitLab 19.2 had recently shipped automatic rebase before merge, and I had gone and turned it on across the estate for the least interesting reason imaginable: Renovate opens a great many merge requests, they go stale against each other constantly, and rebasing each one by hand is not a way to spend a morning.

A release model that leaves a branch waiting on a moving target, then a convenience feature switched on to cope with dependency-bump volume, and between them releases that silently dropped commits. Two decisions of mine, each perfectly defensible on its own, neither of them made with the other in mind.

Nobody else’s fault… and I’d been running it for weeks.

Widening it out from config to the whole of phpboyscout/go on that same day, 9 of 231 recent tags were not on their default branch at all. Not out of date. Not on a side branch somebody forgot to delete. Just… not there. No pipeline had failed, nobody had opened an issue, and every one of those releases had installed perfectly happily for anybody who wanted it.

That’s the part that bothered me. A tag is supposed to be the one thing in a repository you can point at and say there, that’s what shipped. If nine of them can be wrong without anything so much as flickering, then I hadn’t been checking, and I’d have gone on not checking indefinitely.

If you release from a fast-forward project and fancy an uncomfortable five minutes, try it on your own last few:

git fetch --tags && git merge-base --is-ancestor <tag> origin/main

Non-zero means that tag isn’t on your default branch, and the release is missing whatever landed while it was waiting.

The patch first, and then the tool

I did the sensible thing before the fun thing. I cloned releaser-pleaser, which the estate had been releasing with quite happily until then, and wrote a test that failed on exactly this. Then I went through its issues, the closed ones included, to make sure I wasn’t about to report a decision somebody had already taken deliberately, forked it, and raised the bug and a fix under my own name rather than letting an agent file them. Plenty of maintainers would rather not receive an AI-written bug report, and I don’t blame them.

Both are still open as I write this… and that’s fine. It’s a small project, this is a niche interaction with a GitLab feature that was weeks old at the time, and a maintainer’s queue is theirs to order.

I wasn’t going to sit on my hands in the meantime though, and by then I’d stopped thinking of it as one bug anyway. Once you start pulling at this you find three of them, one behind another, all the same shape.

The rebase is the first. Then there’s the forge itself, which will cheerfully tell you a merge request is merged a moment before its commit is reachable on the target branch, so if you ask it which commit landed inside that window you get the wrong one, or nothing at all.

And the third is the one that actually annoys me. Under fast-forward with squash off, a merge request’s description never reaches git. It lives in GitLab’s database and nowhere else. So a tool reading the version, or the release notes, out of that description is reading something the repository does not have. I’d put the release in the queue with everything else precisely so it would have to earn its way onto main, and then quietly handed the most important decision in the process to the one place that isn’t in the repository at all.

None of that is really a bug in releaser-pleaser, which I still rate. It’s an assumption, and a perfectly reasonable one nearly everywhere: the thing you reviewed is the thing you’re tagging. It only comes apart on a trunk that rebases, with Renovate merging behind you and a release request that’s been open an hour. Which is, of course, exactly the trunk I’d built for myself.

So: colophon is public, v0.2.0 is out, and it exists to answer all three at once.

The mark goes at the end of the book

A colophon is the printer’s mark on the last page: the record of what was actually produced, set down once the thing is finished. Not the promise on the cover.

colophon publish resolves the commit against the target branch itself, not from the merge request, and it takes the version from the changelog at that commit, not from the request’s body. Two changes, one reason: git is the only participant here that cannot disagree with reality. The branch knows what’s on it. A file in the commit being tagged cannot drift away from the tag.

Fast-forwards, squashes, rebases, a forge racing ahead of itself… they all end up tagging the right thing, and the pipeline never has to know which of them happened.

Hence the name.

The release notes come from commits for the same reason, out of a Release-Note: trailer or a fenced block in the message. A subject line is a summary; the thing a reader actually needs (this config format changed, edit the file before you upgrade) gets written in the commit body by the person who knew it, and nothing ever looks there. Now something does. It’s in the repository whatever anybody chose to do with the merge request, which is the entire argument.

Three commands, and one of them is free

plan computes the next version and the commits that decided it, and writes nothing at all. No branch, no merge request, no tag.

$ colophon plan
0.40.0 → 0.41.0 (minor)

Decided by 3 commits:
  87a7e68e minor  feat(setup): say which refusal a forge returned
  29f61ac2 patch  fix(deps): take the forge family to the current round
  15734d14 patch  perf(generator): let a generation skip the golangci-lint pass

7 commits moved nothing.

It needs no credentials, on purpose. Point it at any checkout you have and it’ll tell you what it would do, which makes it the instrument for comparing colophon against whatever you release with today before you change anything. That last line is doing real work too… a tool that only lists the commits that counted leaves you unable to tell a commit correctly ignored from one whose type somebody fat-fingered.

propose re-cuts the release branch from its target on every run and opens or updates the merge request. Re-cutting rather than merging forward is deliberate: a branch that’s merged forward can fall behind, a branch cut fresh cannot, so the request always shows the release as it would happen right now.

publish finds the merged request, resolves what landed, tags it, and creates the release.

There are two opinions baked in there that differ from what I was using, and you may well disagree with either. perf and refactor earn a patch release, because a performance fix a user can actually feel shouldn’t sit unreleased waiting for something else to come along and ship it.

And below 1.0, a breaking change is held to a minor, with colophon saying so in its output rather than just doing it and hoping you noticed. Declaring a stable API is a promise to the people depending on you, and it shouldn’t fall out of a commit message written on a Friday afternoon. Promoting to 1.0 is an explicit Release-As: 1.0.0 trailer, made by a person who meant it.

The release that exists before its binaries do

Colophon’s own releases carry binaries, and two tools make them. It decides the version and cuts the tag. goreleaser compiles six platforms’ worth of binary, signs the checksums with a KMS key and packages the lot. Neither can go first.

The reason neither can is that goreleaser takes its version from the tag, since that’s how the number gets baked into the binary it’s building, so it can only run on the tag pipeline, once the tag is already there. Which means that if colophon creates the release at the moment it cuts the tag, that release exists before a single one of its binaries does.

That empty release answers yes to the only question a consumer knows how to ask. Something wants 1.2.0, asks the forge whether it’s there, is told it is, fetches an asset… and gets a 404. I have two of these on the record: a container build pulling a binary moments after the release appeared, and a CLI’s self-update doing the same thing. Both surfaced as a broken download, miles from the actual cause.

So the two are split by ownership, explicitly. In colophon’s own .goreleaser.yaml:

release:
  disable: true

uploads:
  - name: gitlab-generic
    target: '{{ .Env.CI_API_V4_URL }}/projects/{{ .Env.CI_PROJECT_ID }}/packages/generic/{{ .ProjectName }}/{{ .Version }}/{{ .ArtifactName }}'
    method: PUT
    mode: archive
    checksum: true
    signature: true

disable, not skip_upload. The upload still has to happen, it just happens down a pipe that doesn’t need a release to exist, and that independence is the entire mechanism.

So goreleaser builds and uploads, and colophon owns the release. On the default branch colophon publish tags and then stops. A separate job on the tag pipeline creates the release afterwards, last, carrying links to what goreleaser has already put in the package registry. Where a forge can’t manage a release and its assets in one go, colophon opens it as a draft and flips it, and a draft isn’t visible without write access, so from where you’re standing the effect is identical.

The two configs have to agree about where the bytes live, and colophon deliberately doesn’t read that out of your build’s settings. Writing the path twice means a mismatch is a configuration error you can see, rather than a release full of links that return 404.

It then checks every one of those links before it publishes anything, because a forge records where an asset lives without ever fetching it, and a release can be complete and correct by every contract involved while every link on it is dead.

Which retires a check I’d been relying on, and I’d rather say so than let somebody keep using it. Does the release have assets? used to distinguish a good release from a broken one. It doesn’t any more. Fetch one instead of counting them.

Same instinct as the tag, really, pointed at a different window.

Where it stands

Pre-1.0 and moving, and it cuts its own releases, so every tag on that repository is one colophon resolved and created itself. Docs are at colophon.phpboyscout.uk, and it installs the usual way:

go install gitlab.com/phpboyscout/colophon/cmd/colophon@latest

Pin the tag in a pipeline rather than tracking @latest. It decides what ships, and you want a decision like that coming from a commit somebody wrote, not from whatever happened to be current when the job ran.

I’d assumed for years that a tag was a fact. It’s a pointer somebody’s tooling chose, and mine had been choosing badly in a way that never once complained. Nine times out of two hundred and thirty-one it wrote the mark on a page that wasn’t in the book.

Built with Hugo · Theme Stack designed by Jimmy