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_SHA — release 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¶
- Reference:
goreleaser,release-plz,tofu-module-publish,releaser-pleaser - Security, always-on — why the Release MR is the real gate.
- How-to: Publish a release
- Specs:
2026-05-27-module-publish-v0.7,2026-06-03-go-track-v0.9,2026-06-03-rust-track-v0.10,2026-06-21-releaser-pleaser-component-v0.11