For a long time my releases happened by accident. Not literally, but close enough. The setup was tag-on-merge, the semantic-release way: you merge to the release branch, the tooling reads your commits, works out the next version, and a tag pops out the other end. A release happens. You didn’t do a release, exactly. You merged a thing, and a release fell out of it.
That feels great right up until you ask an awkward question: where, in that flow, do the tests run?
Because a release that’s a side-effect of a merge rides whatever pipeline that merge triggers, which is the main-branch pipeline. And your quality gates, the lint and the tests and the security scans, are then in an uncomfortable spot. Either they run on that same main-branch pipeline, which means they fire after the merge that already triggered the release, and you’re checking the work after you’ve shipped it. Or they ran back on the merge request and you’re trusting that, hoping nobody merged something red, hoping the MR pipeline and the main pipeline actually agree about what “passing” means. It’s closing the stable door a moment after the horse has bolted, been packaged, tagged, and handed to the public.
I lived with that longer than I’d like, because it mostly works. Most merges are fine. The gates mostly did run on the MR. “Mostly” is doing an awful lot of load-bearing in those sentences, though, and “mostly” is not what you want underneath the thing that decides whether unreviewed code becomes a versioned release.
The fix turned out to be a change of model, not a patch. I moved the estate to releaser-pleaser, which does releases as a Release-PR. Instead of a tag falling out of your feature merge, a bot keeps a pull request open, the one that bumps the version and assembles the changelog, and the release only happens when that PR is merged. Which sounds like a small bit of plumbing rearranged, and that’s the whole of it, because it changes what a release is. The release stops being a consequence of merging your feature and becomes its own explicit merge request.
And here’s the thing I was really after: a merge request runs the gates. My cicd templates default their rules to $CI_PIPELINE_SOURCE == "merge_request_event", so lint, test and security are things that happen to merge requests. The moment the release is a merge request, the release is gated, automatically, by the exact same checks as every other change, with no special handling at all. Nothing becomes a release without going through the door that everything else goes through. I didn’t have to bolt a gate onto the release process. I made the release a merge request and it inherited every gate I already had.
There’s a knock-on that I didn’t expect to care about as much as I do. Once the release is a proper PR off the main branch, the old develop intermediary I’d been carrying had nothing left to do, so it went, and the whole thing collapsed into trunk-based development. That’s a conversion I came to from the other direction entirely, and it has its own story, but the release model and the branching model turned out to be the same decision wearing two hats. A simpler branch structure and a gated release fell out of one change.
I rolled it across everything rather than one repo, because a delivery model that’s different in each project is its own kind of tax. Every terraform-aws-* module, cicd itself, the lot, all flipped to trunk-based with releaser-pleaser, with the reusable pieces pulled into shared cicd components so the gating lives in one place and not in twelve. (The Rust side went to release-plz, a different tool with the same Release-PR idea and a war story all its own.) The wrapping of those components, and the schedule gotcha that came with them, is a tale I’ve already told.
What it comes down to is a question I’d never quite asked out loud: should a release be allowed to skip the queue? With tag-on-merge I’d answered yes, a release was a privileged thing that happened off to the side of my normal review-and-gate flow. The Release-PR model answers no, and I think no is right. A release is a change to your project like any other. It should have to stand in the same line, pass the same checks, and earn its way to main exactly like the smallest bugfix does. Make it a merge request, and it does.
