Skip to content

Publish a release

Which component you need depends on what you're releasing. Read Explanation: release automation for the shared reasoning; this page is the setup steps per ecosystem.

Prerequisite: a tag-safe workflow: block

Every publish job below runs on the tag pipeline cut when your release tag lands. If your repo has a workflow: block that suppresses duplicate branch pipelines, its never rule must be guarded so it does not also swallow the tag pipeline. Use the canonical GitLab form:

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS'
      when: never          # dedupe branch+MR — $CI_COMMIT_BRANCH is empty on tags
    - if: '$CI_COMMIT_TAG'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "schedule"'

Do not key the dedupe rule on $CI_PIPELINE_SOURCE == \"push\"

A tag push is also $CI_PIPELINE_SOURCE == "push". A rule like $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" → never fires before the $CI_COMMIT_TAG rule whenever the tagged commit is associated with an open MR (which the releaser-pleaser flow makes likely), suppressing the entire tag pipeline — GitLab reports "the resulting pipeline would have been empty" and your release never publishes. The $CI_COMMIT_BRANCH guard above is empty on tag pipelines, so it can only ever dedupe branch pushes. See the tag-pipeline-workflow-guard spec.

Go / Conventional-Commits projects → releaser-pleaser

include:
  - component: gitlab.com/phpboyscout/cicd/[email protected]
    inputs:
      token: $RELEASER_PLEASER_TOKEN
      branch: main
      stage: release
  - component: gitlab.com/phpboyscout/cicd/[email protected]
  1. Create the token. $RELEASER_PLEASER_TOKEN needs api + write_repository scope — a project or group access token, not $CI_JOB_TOKEN (a job-token-pushed tag doesn't fire downstream tag pipelines, so goreleaser would never run). Store it as a project CI/CD variable, Masked + Protected.
  2. Use Conventional Commits on every merge to your default branch. releaser-pleaser derives the next version and changelog from them.
  3. Merge normally. On each push to main, releaser-pleaser opens or updates a Release MR with the computed version bump and generated changelog.
  4. Review and merge the Release MR. Merging it cuts the vX.Y.Z tag — that's what triggers goreleaser to build and publish the release binaries.
  5. Check the tag is on your default branch — see below.

Check the tag landed on the default branch

Fast-forward + automatic rebase can tag a commit that is not on main

If your project uses merge_method: ff with GitLab 19.2's automatic rebase before merge, and anything merged to the default branch while the Release MR was open, the tag can be cut at the Release MR's pre-rebase head — a commit that is not an ancestor of your default branch. Everything that landed in between is then missing from the tagged tree.

Automatic rebase is what allows a Release MR that is behind its target to be merged at all; GitLab rebases it during the merge and never writes that rebase back to the MR. Without the setting, GitLab blocks the merge until someone rebases explicitly — which does update the MR — so a project without it is not exposed.

Tracked as cicd#7.

The component now checks this for you

From releaser-pleaser v0.34.1 the releaser-pleaser:verify job makes the Release MR squash on merge — which records a squash_commit_sha, a commit GitLab creates on top of the current target head and so always on the branch — and asserts the tag is on the release branch after every release, failing the job if it is not. No project-wide squash setting is needed, and a Release MR is a single commit so squashing it changes nothing. The manual check below is still what to run on a release cut before you picked the new component version up.

Only commits that do not change the changelog can be lost this way (docs, chore, chore(deps), ci, style, test) — a feat/fix forces releaser-pleaser to refresh the Release MR onto the current branch, which pulls everything before it in. So a code change cannot be dropped by this mechanism. See spec 0061.

After each release:

git fetch --tags
git merge-base --is-ancestor "$TAG" origin/main && echo ok || echo "TAG NOT ON MAIN"

A non-zero exit means the release is missing commits. To see exactly what:

git diff --stat "$TAG" origin/main

Worth doing even though it usually passes, because the failure is silent and the window is ordinary practice rather than an edge case: a Release MR sits open, a normal MR lands, and the rebase happens on merge.

If the tag is wrong, how much it matters depends on what was missed:

  • Documentation only — the docs site deploys from the default branch as well, so the published site is already correct. Note it and move on; the next release picks the commits up.
  • Code — for a Go module this is not repairable in place. Once proxy.golang.org has served a version it is immutable, so the fix is to release again immediately from a default branch that has everything, and to treat the bad version as skipped.

Rust crates → release-plz

include:
  - component: gitlab.com/phpboyscout/cicd/[email protected]
  1. Create two tokens: $RELEASE_PLZ_TOKEN (api + write_repository, same constraint as above — not $CI_JOB_TOKEN) and $CARGO_REGISTRY_TOKEN (your crates.io API token, for cargo publish). Both Masked + Protected CI/CD variables.
  2. Use Conventional Commits. Same as above — release-plz derives versions and per-crate changelogs from them.
  3. Merge normally. release-plz:pr opens/updates the Release MR on every push to the default branch.
  4. Review and merge the Release MR. release-plz:release then publishes every crate (in dependency order) and cuts the tag + GitLab release.

Terraform / OpenTofu modules → tofu-module-publish

include:
  - component: gitlab.com/phpboyscout/cicd/[email protected]
    inputs:
      module_name: bootstrap
      module_system: aws

This one doesn't drive a Release MR at all — it publishes whatever tag you cut, by hand or via another tool, to the GitLab Terraform Module Registry. No token setup needed for the common case: the default token ($CI_JOB_TOKEN) has write access to your own project's package registry, even on GitLab Free.

Once published, consumers reference your module by registry address instead of a git:: tag ref:

module "bootstrap" {
  source  = "gitlab.com/<your-namespace>/bootstrap/aws"
  version = "1.2.3"
}

Using more than one at a time

A repo can combine these where it makes sense — e.g. a project publishing both a Go binary (releaser-pleaser + goreleaser) and a Terraform module (tofu-module-publish) from the same tag pipeline runs all three components together; they don't conflict.

See also