I run two release drivers across my estate, because the two language ecosystems have different best-in-class tools and I’d rather use each than force one to do both. The Go projects use releaser-pleaser; the Rust ones use release-plz. That split is a story of its own. This is about something smaller and more annoying that fell out of it: a release driver that kept going off when it had no business going off.
releaser-pleaser ships as a GitLab CI component, and rather than reinvent it I started from Apricote’s upstream one. It does its job. But it decides when to run by gating on the branch: branch == "<default>", which is a perfectly sensible way to say “only manage releases on the main branch.” The trouble is that a Renovate schedule also runs on the default branch. A scheduled pipeline and a push-to-default pipeline both satisfy branch == default, so the component couldn’t tell them apart. Every time Renovate woke up to check for dependency updates, the release driver fired right alongside it. Wasted pipeline minutes at best, and at worst a release driver doing release-shaped things on a schedule nobody asked it to.
The tempting fix, adding a rule to each job to bail out on scheduled runs, just spreads the same workaround everywhere. I could add an override in the consuming repo, a little extra rules: stanza to exclude schedules, and move on. And then do it again in the next repo. And the next. Every project that used the component would carry its own copy of the same patch, and the day Apricote changed something upstream, I’d be reconciling that change against a dozen hand-rolled overrides scattered across the estate. That’s not a fix, it’s a debt with interest.
What actually fixes it is to stop consuming the upstream component directly and wrap it. I now have my own releaser-pleaser component that uses Apricote’s image underneath but bakes in the org’s conventions, the schedule guard included, at the wrapper layer. The repos consume my component. The gating is correct in exactly one place. When upstream changes, there’s one wrapper to reconcile, not a dozen repos. The principle is the one I keep coming back to as the person maintaining shared CI for a whole estate: when you don’t control a dependency’s gating, don’t patch it everywhere it’s used. Wrap it once, put your conventions in the wrapper, and let everything consume that.
There’s a sharp little gotcha buried in this that’s worth pulling out, because it bites everyone building release automation on GitLab eventually. When a job pushes a tag using CI_JOB_TOKEN, that tag will not trigger a downstream pipeline. It’s a deliberate loop-prevention in GitLab: a job’s own token isn’t allowed to kick off more pipelines, or you’d have CI triggering CI triggering CI. Which is fine until your release flow is “the driver pushes a tag, and the tag is supposed to start the release pipeline.” Push that tag with the job token and the release pipeline simply never fires, and you’re left staring at a tag that exists and a release that didn’t happen, with nothing in the logs to explain it. The fix is to push the tag with a token that is allowed to trigger pipelines, but the failure mode is silent, so you have to know it’s there.
This is roughly as thrilling as sorting the recycling: running releases across a handful of repos with a couple of different drivers, on a runner I keep small on purpose. But it goes wider than CI, really. The third time you paste the same workaround into a new place, it stops being a workaround. It’s a feature you haven’t admitted to yet.
