Skip to content

Releasing from CI without retriggering yourself

A release job that pushes a version-bump commit triggers a new pipeline, and two merges landing close together race each other to npm. Serializing releases with resource_group, authenticating the push, and the difference between [skip ci] and -o ci.skip.

5 min read
  • gitlab
  • ci-cd
  • devops

Part 9 left the monorepo pipeline building only what changed. The step after that is publishing: when a change to a shared package lands on the default branch, that package needs a new version on npm. The obvious design is a job on the default branch that bumps versions, publishes, and pushes the version-bump commit back to the repo.

That push is the problem. A push to the default branch is exactly what triggers the pipeline, so the release job schedules a full pipeline run whose only purpose is to process a commit the pipeline itself created. If the versioning logic isn’t perfectly idempotent, that second run can publish again. And there’s a second failure mode with nothing to do with the loop: two MRs merged a few minutes apart produce two pipelines, both reach the release job, and both try to version and push. One of them loses the race and fails at git push, or worse, both publish.

The release flow in one job

We use Changesets, and the tool specifics can stay brief because the CI mechanics are the same for any release tool that versions from committed metadata. Each MR that touches a publishable package includes a changeset, a small markdown file saying which packages changed and whether it’s a patch, minor, or major. On the default branch, a job consumes those files, rewrites versions and changelogs, publishes, and pushes the resulting commit and tags back:

release:
  stage: release
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - pnpm changeset version
    - git commit -am "chore: release packages"
    - pnpm changeset publish
    - git push --follow-tags origin "HEAD:$CI_DEFAULT_BRANCH"

The rules line is the same gate from part 5. Without it, every feature branch would try to release.

As written, this job has all three problems from the intro: nothing stops two pipelines from running it concurrently, the runner has no credentials to push, and if the push succeeds it triggers the next pipeline. The rest of the post fixes them one at a time.

One release at a time

resource_group appeared briefly in part 6, keeping two deploy jobs from touching the same environment at once. Releases need the identical treatment. Jobs that share a resource_group value run one at a time, and the serialization holds across pipelines, not just within one:

release:
  resource_group: npm-release

Now two merges landing close together queue instead of racing: the second pipeline’s release job waits until the first one finishes. By default the queue is processed in unordered mode; for releases it’s worth switching the group to oldest_first (an API setting on the resource group) so releases go out in the order the pipelines were created.

One thing resource_group does not do is reconcile git history. The second release job checked out its own commit, and by the time it runs, the first job has already moved the branch tip. Its push would be rejected as non-fast-forward, so the script needs a git pull --rebase before pushing, and the version step has to run after that pull, not before.

Authenticating the push

The runner clones the repo with an ephemeral job token, and by default that token cannot push. GitLab does have a project setting that allows Git push with the CI job token (it’s off by default, and the docs carry warnings about enabling it on mirrored projects), but the standard approach is a project access token: create one with the write_repository scope, store it as a CI/CD variable that is both masked and protected, and rewrite the remote before pushing:

release:
  script:
    - git remote set-url origin
      "https://ci-release:${RELEASE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
    # ...version, publish, push as before

The username can be any non-blank value; the token is the password. Masked keeps it out of job logs. Protected means only pipelines on protected branches receive the variable at all, which pairs with the rules gate: a feature-branch pipeline that somehow ran this job would find RELEASE_TOKEN empty.

Publishing to npm needs its own token as a second masked variable, but that one is an environment variable the tool reads. The git side is where GitLab-specific decisions live.

Not triggering the next pipeline

GitLab skips the pipeline for any commit whose message contains [skip ci] or [ci skip], in any capitalization. So the cheapest fix is one string in the bump commit:

git commit -am "chore: release packages [skip ci]"

The alternative is the push option, git push -o ci.skip (Git 2.10 or later), which skips the pipeline for that push without touching the commit message. Two differences decide between them. The marker in the message travels with the commit forever, which makes the skip self-documenting in history and independent of how the commit reaches the server. The push option keeps messages clean but is a per-push decision, and per the docs it does not skip merge request pipelines, which matters if your release flow pushes bump commits to a branch that has an open MR (the “version packages” MR pattern) rather than straight to the default branch.

For a bot pushing directly to the default branch, both work; we use [skip ci] because the evidence of why no pipeline ran sits right in the commit log. And if you did enable job-token pushes in the previous section, note that those pushes trigger no pipelines at all, no marker needed.

Where the part 5 regex bug came from

Part 5 told the story of a rule with $CI_COMMIT_MESSAGE =~ /[skip ci]/ that silently matched nearly every commit, because unescaped square brackets are a character class: the pattern matches any single one of those characters. This release job is where that rule was born. Before trusting GitLab’s built-in handling, someone added a workflow rule to filter out release-bump pipelines by matching the commit message, and the character class made almost every push skip CI. The fix at the time was escaping the brackets; the real fix was deleting the rule, since GitLab already skips [skip ci] commits natively.

Give the bot its own name

A commit needs a committer, so the job has to set user.name and user.email. We had a personal email hardcoded there for over a year. Every release in the history was attributed to one engineer, their name showed up in changelog commits for packages they never touched, and the day they left, the identity became a small archaeology problem: an author no one could ask, attached to automation everyone depended on.

The project access token from earlier solves this for free. Creating one also creates a bot user in the project, with a generated username and a noreply email, and contributions made with the token are attributed to that bot. Configure the commit identity to match:

release:
  before_script:
    - git config user.name "release-bot"
    - git config user.email "project_42_bot@noreply.example.com"

Any role-based identity works; what matters is that no single person owns it. Rotating the token later doesn’t rewrite anyone’s contribution history.

Where this leaves you

Merges to the default branch now publish exactly once: serialized by resource_group, pushed back by a bot with a scoped token, and marked so the bump commit doesn’t wake the pipeline that created it. That closes the loop the intro opened.

It also closes a bigger arc: everything in this series so far reacts to a push. Some signal has no push to react to. The E2E suite is too slow and flaky to gate merges, but the team still needs its verdict every day, which means some pipelines have to run on a clock instead of a commit. Part 11 is about scheduled pipelines.

References