Skip to content

Release automation

Four components cut releases, one per ecosystem: goreleaser (Go binaries), release-plz (Rust crates), tofu-module-publish (Terraform modules), and releaser-pleaser (the Go-track sibling of release-plz for Conventional-Commits projects that aren't crates). They share a philosophy even though their mechanics differ ecosystem to ecosystem.

The shared shape: a Release MR is the real gate

releaser-pleaser and release-plz both drive a trunk-based, Release-MR-first flow: work lands on the default branch via ordinary merge requests; on every push, the release tool opens or updates a Release MR with the next version and a generated changelog derived from Conventional Commit messages since the last tag. Merging that MR is what cuts the tag.

This matters more than it looks: by the time a tag exists, every line of code in it was already gated when its own feature MR merged — so the tag pipeline can simply build, as-is, no further gating. The Release MR itself is a merge_request_event, so it goes through the full MR gate including every always-on security scanner — which means the security suite re-runs, against the latest advisory database, immediately before a version is cut. That's the actual gate that matters for a release: not the tag pipeline, the Release MR.

The token that must not be $CI_JOB_TOKEN

releaser-pleaser, release-plz, and tofu-module-publish all need a token — but for releaser-pleaser and release-plz specifically, that token has one hard constraint the token-input convention elsewhere doesn't impose: it must not default-through-to CI_JOB_TOKEN for the actual push. A tag pushed by CI_JOB_TOKEN does not trigger downstream tag pipelines — GitLab's own loop-prevention stops it — so the goreleaser (or crate-publish) job that's supposed to run on that tag would simply never fire. Both components default their token input to a distinct CI/CD variable ($RELEASER_PLEASER_TOKEN, $RELEASE_PLZ_TOKEN) instead, each needing api + write_repository scope.

Why release-plz is two jobs, not one

release-plz splits into release-plz:pr and release-plz:release as separate jobs, not two script lines in one job. release-plz release-pr mutates the working tree — it checks out the Release MR branch, git reset --hards, and rebases. Running release-plz release in that same mutated shell would read cargo metadata against the pre-bump version and silently skip the publish, concluding every crate was "already published." Two jobs means two clean checkouts of $CI_COMMIT_SHArelease always sees the just-merged, version-bumped tree.

pr also runs needs: [release-plz:release] (optional: true), ordering it after release within the stage. On the pipeline that merges a Release MR, pr needs to see the post-release state (registry index and tags already updated) to compute the next version correctly — running them concurrently risks pr observing a mid-publish view and proposing a spurious, premature Release MR.

Why releaser-pleaser isn't just apricote's component directly

releaser-pleaser wraps apricote/releaser-pleaser rather than including it directly, for one specific reason: apricote's run component gates on $CI_COMMIT_BRANCH == "<branch>", which also matches a renovate-self schedule running on the default branch — so, used directly, it would fire on every scheduled pipeline too. The wrapper leads its rules: with the project's standard schedule-never guard, so a scheduled pipeline runs Renovate only, never a spurious release check.

Where goreleaser and tofu-module-publish differ

Not every ecosystem needs the Release-MR machinery. goreleaser is tag-gated directly (tag_pattern, strict semver by default) and simply attaches release binaries to whatever Release the upstream tool already created — it doesn't decide when to release, only what to build once a release tag exists. tofu-module-publish is even simpler: it runs in the built-in .post stage of every tag pipeline matching tag_pattern, uploading the tagged tree to the GitLab Terraform Module Registry with no Release-MR step at all — module versioning follows whatever tags the consuming project already cuts by hand or via releaser-pleaser.

goreleaser retries transient failures (network blips, a notarization timestamp-server timeout) up to retry_max times, because a single transient otherwise fails the entire release run and publishes zero assets — exactly what happened to a real release before the retry was added. release.mode: keep-existing (in the consumer's own .goreleaser.yaml) is what makes a retry safe: it attaches to the release the Release-MR tool already created rather than trying to create a duplicate.

See also