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.

Conventional-Commits projects → colophon

The estate's release tool. It keeps a Release MR open on the default branch, and merging that MR cuts the tag.

include:
  - component: gitlab.com/phpboyscout/cicd/colophon@main
    inputs:
      stage: release
      branch: main
      # Only for a project whose releases carry binaries:
      assets: true
      asset_base: "https://pkg.phpboyscout.uk/<project>/$CI_COMMIT_TAG"
  - component: gitlab.com/phpboyscout/cicd/goreleaser@main
  1. Use Conventional Commits on every merge. colophon derives the version and the changelog from them; release-note prose goes in a Release-Note: trailer on the commit, before it merges. Nothing written in the Release MR description survives.
  2. Merge the Release MR when you want the release. Merging tags; with assets: true the tag pipeline builds and then colophon-release creates the release carrying the artefacts.

Every input, the token and the swap from releaser-pleaser are on the colophon reference page.

When colophon cannot cut the release

Push an annotated vX.Y.Z tag by hand. Every artefact job keys off the tag, not off which tool made it, and for an assets: true project colophon-release creates the release from the tag on its own. A project without assets needs one command afterwards. The steps are in colophon's runbook, Recover with a hand-cut tag.

Not yet migrated → releaser-pleaser

Some repositories still release with releaser-pleaser. Its procedure has nothing in common with colophon's, so check which a repository uses before following either: grep its .gitlab-ci.yml for colophon or 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 (colophon + goreleaser) and a Terraform module (tofu-module-publish) from the same tag pipeline runs all three components together; they don't conflict.

See also