Skip to content

Set up the version gate in GitLab CI

Make a merge request fail when a segment's version is behind what its commits warrant, so a change cannot reach the default branch without shipping.

Time: about ten minutes. You need: Maintainer on the project.


Why a gate and not an automatic bump

The obvious design is to let CI compute the bump, commit it and push it back. It works right up to the last step, and then GitLab stops you:

  • A job token cannot create a pipeline. The job-token API grants only PUT /projects/:id/pipelines/:pipeline_id/metadata; creating one needs a pipeline trigger token, a separate credential.
  • A job-token push triggers no pipeline. That is deliberate — it is what stops a pushing job re-triggering itself forever.

Together those mean a bump written by CI arrives as a head commit with no pipeline of its own, and if the project has Pipelines must succeed enabled, the merge request blocks on ci_must_pass with nothing able to clear it.

So skillup runs in CI as a gate that only reads, and the contributor runs skillup apply when it tells them to. One command, run occasionally, against a second credential and a permanently-blocked merge request.

GitHub's GITHUB_TOKEN behaves the same way. See Set up the version gate in GitHub Actions.


1. Protect the default branch

The gate only means something if nothing can bypass it.

Settings → Repository → Protected branches, and for main:

Setting Value
Allowed to push and merge No one
Allowed to merge Maintainers
Allow force push Off

The branch rule for main: Allowed to merge is Maintainers, Allowed to push and
merge is No one, and Allow force push is off.

Allowed to push and merge → No one is the one that matters. Without it a maintainer can push straight to main, and a commit that never passes through a merge request never meets the gate.

Then Settings → Merge requests:

  • Pipelines must succeed — on
  • All threads must be resolved — optional, but it pairs well

Merge checks: Pipelines must succeed is ticked, Skipped pipelines are
considered successful is not, and All threads must be resolved is
ticked.

Leave Skipped pipelines are considered successful unticked. A skipped pipeline is not a passed one, and ticking it would let a merge request through on a pipeline that never evaluated the gate.

2. Add the job

This is the job as it actually runs on phpboyscout/claude-code-plugins:

stages:
  - security
  - version

version-check:
  stage: version
  image: registry.gitlab.com/phpboyscout/images/dev-tools:v0.3.3
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  variables:
    # skillup reads each manifest's whole history. GitLab clones shallow by
    # default, and a shallow clone yields a confidently wrong answer rather
    # than an error.
    GIT_DEPTH: "0"
  before_script:
    # Pinned, not @latest: this gate decides whether a change ships, so it
    # should not change underneath the repository without a commit saying so.
    - go install gitlab.com/phpboyscout/skillup/cmd/skillup@v0.1.0
  script:
    - skillup check --path .

Three details that matter:

  • GIT_DEPTH: "0". Without it the clone is shallow, skillup sees a truncated history, and computes a version that is wrong without failing.
  • Pin the version; do not track @latest. This job decides whether a change reaches consumers. On @latest that decision changes without a commit saying so, and the change lands on whichever merge request happens to run next.
  • Put the security stage first if you have one. A version job is a convenience; a secrets scan is not, and a failure in the former should never stop the latter running.

Any image with Go 1.26.5 or newer will do. The dev-tools image is used above because it is already the group's standard; a stock golang image works identically.

Keep the pin current

A pin that nobody moves is a pin that rots, and a go install line in a CI file is not covered by an ecosystem preset. Renovate's gomod manager reads go.mod; it does not read shell commands in a pipeline, so extending a :go leaf will not bump this line. It needs a custom manager, added to the consuming repository's renovate.json:

{
  "customManagers": [
    {
      "customType": "regex",
      "managerFilePatterns": ["/\\.gitlab-ci\\.ya?ml$/"],
      "matchStrings": [
        "go install gitlab\\.com/phpboyscout/skillup/cmd/skillup@(?<currentValue>v[\\d.]+)"
      ],
      "datasourceTemplate": "gitlab-tags",
      "depNameTemplate": "phpboyscout/skillup"
    }
  ]
}

Without it the pin is correct and permanent, which is the safe failure but not the one you want. Check for an open merge request after a skillup release; if none arrives, this is why.

3. Check it bites

On a branch, change something inside a segment and commit it as a feat: or fix: without touching the version. The job should fail with:

  my-segment    is 0.3.0 but its commits warrant 0.4.0 (minor)
      a1b2c3d4  feat(my-segment): add a thing (minor)

  To fix, on your branch:

      skillup apply --path .
      git add -u && git commit --amend --no-edit   # or a new commit

Run those two commands, push, and it goes green.


Troubleshooting

The job passes but should not. Check GIT_DEPTH: "0" is set. A shallow clone is the usual cause of a wrong-but-confident answer.

Every segment wants to go to 0.1.0. skillup is reading a repository whose manifests have no history, so every segment looks new. Confirm you are pointing --path at the right root.

The job is skipped. The rules limit it to merge request pipelines by design. If you push directly to a branch with no merge request, it will not run — which is why step 1 matters.