Skip to content

Tagging what actually landed

This is the idea colophon exists for. Everything else it does is ordinary; this part is where most release tooling is subtly wrong.

The problem

When a Release merge request merges, something has to decide which commit gets the tag. The obvious sources are both unreliable.

The merge request's recorded head is not necessarily what landed. Upstream tooling commonly picks the merge commit, else the squash commit, else the merge request's recorded head. GitLab's automatic rebase never writes the rebase back to that record, so under rebase-and-fast-forward the recorded head is a commit that no longer exists on the target branch. Measured across phpboyscout/go on 2026-08-01: 9 of 231 recent tags were not on their default branch at all.

The merge request's body is not in git. Under a fast-forward merge with squash off, the description exists only in the forge's database. Anything that reads the version out of it is reading something the repository does not have.

And the forge can answer too early. A forge will report a request merged a moment before its commit is reachable on the target branch. Ask which commit landed in that window and you get the wrong one, or nothing.

What colophon does instead

publish resolves the commit against the target branch itself, not from the merge request, and takes the version from the changelog at that commit, not from the request's body.

Both halves matter. Resolving against the target means the answer is whatever git says is actually there, which is the only source that cannot disagree with reality. Taking the version from the changelog means it comes from a file that is in the commit being tagged, so the tag and its version cannot drift apart.

The result is that fast-forward merges, squashes, rebases and a forge racing ahead of itself all end up tagging the right thing, without the pipeline needing to know which of them happened.

Why publish runs before propose

There is a second correctness constraint, and it lives in the pipeline rather than the code.

After a Release merge request merges, the release commit is on the target branch but the tag does not exist yet. In that window colophon computes the same release again, because the tag is what tells it the release happened. Measured on colophon itself:

$ colophon plan          # tag present
No release due — 0.1.0, 0 commits considered

$ colophon plan          # same commit, tag removed
0.0.0 → 0.1.0 (minor), decided by 30 commits

So a pipeline that proposed first would re-open a Release merge request for the version about to be tagged. Publish would then tag it, propose would find nothing due, and that merge request would sit open forever proposing a release that has already happened.

publish first closes the window, and the two run as separate jobs. That separation is the part that is easy to miss, because both jobs check out the same commit and a commit does not carry its own tags. publish tags through its own clone and pushes to the remote; it never writes into the job's checkout. propose reads the last release from the tags in its checkout, which are whatever the runner fetched when that job started. So propose sees the new tag only because it is a separate job whose fetch happens after publish pushed. It is a timing property, not a cloning one, and folding the two into one script gives you a single fetch taken before the tag exists.

colophon carries a backstop for this anyway: it refuses to propose a version whose release commit is already at the tip, and says so.

Why the branch is re-cut every time

propose re-cuts the release branch from its target on every run rather than keeping it up to date with the target. A branch that is merged forward can fall behind; a branch that is cut fresh cannot. The merge request therefore always shows the release as it would happen from the current state of the target, and never a stale one.

See also