Skip to content

Rules in GitLab CI: controlling when jobs run

Every job runs on every push: deploy jobs tag along on feature branches, and expensive suites run when nothing relevant changed. Job-level rules (if, changes, exists), manual gates, and a regex that silently matched almost every commit message.

5 min read
  • gitlab
  • ci-cd
  • devops

Part 4 ended with a pipeline that is finally fast: needs turned the stage sequence into a dependency graph and parallel spread the slowest jobs across runners. Nothing so far controls when any of it runs. Push a feature branch and the production deploy job shows up anyway, ready to ship code nobody merged. Touch only the docs folder and the full build-and-test gauntlet runs as if you had rewritten the app.

The job-level fix is rules: a list of conditions evaluated when the pipeline is created, deciding for each job whether it belongs in this particular pipeline and in what mode. Job-level is the operative phrase here. There is also pipeline-level control, and the distinction matters more than it sounds; that comes in part 6.

First match wins, no match means no job

The simplest useful rule keeps a deploy job off feature branches:

deploy-production:
  stage: deploy
  script: ./deploy.sh
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

if conditions are expressions over CI/CD variables, usually the predefined ones: CI_COMMIT_BRANCH is the branch being built, CI_DEFAULT_BRANCH is whatever the repo calls its main branch, and CI_PIPELINE_SOURCE tells you what created the pipeline (push, merge_request_event, schedule, among others).

Two mechanics govern the whole keyword. Rules are evaluated top to bottom, and the first entry that matches decides what happens to the job; everything after it is ignored. And if no entry matches, the job is not added to the pipeline at all. There is no “skipped” state to spot in the UI; the job simply isn’t in the list.

That second mechanic makes ordering meaningful. Exclusions go first:

deploy-production:
  stage: deploy
  script: ./deploy.sh
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

A scheduled pipeline hits the first rule, when: never fires, and the job stays out even though the second rule would also have matched. Swap the two entries and scheduled pipelines start deploying.

When a job you expected is missing, the pipeline view won’t tell you which rule excluded it. You have to replay the list yourself, top to bottom, against the concrete values of that pipeline: the branch name, the CI_PIPELINE_SOURCE, all visible in the pipeline’s details.

changes: react to what the push touched

The second lever is running jobs only when relevant paths changed:

build-docs:
  stage: build
  script: pnpm --filter docs build
  rules:
    - changes:
        - docs/**/*

If the push touched nothing under docs/, the job isn’t added. The paths are glob patterns, and you can list several.

A single rule can also combine keywords, and they AND together. Every keyword in the entry has to hold:

docker-build:
  stage: build
  script: docker build -t my-app .
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      changes:
        - Dockerfile
        - docker/**/*

This builds the image only on the default branch, and only when Docker-related files moved. The comparison base for changes (what exactly the push is diffed against) becomes a real question in monorepos, where rules:changes:compare_to earns its keep; part 9 goes deep on that.

exists: react to what the repo contains

changes asks what this push modified; exists asks what the repository has:

docker-build:
  stage: build
  script: docker build -t my-app .
  rules:
    - exists:
        - Dockerfile

Inside a single repo you control, this looks pointless — you already know whether there’s a Dockerfile. It starts paying off when one CI configuration serves many repos (part 7 builds exactly that): the job is defined once for everyone and materializes only where a Dockerfile exists. Inverted with when: never, it works as a guard: skip a job when some config file is present, run a fallback job when it isn’t.

when: manual gates and soft gates

When a rule matches and says nothing else, the job is added with when: on_success: run once the jobs it depends on have succeeded, the normal behavior you’ve had since part 1. You’ve already seen never. The third value you’ll actually reach for is manual:

deploy-staging:
  stage: deploy
  script: ./deploy.sh staging
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
      allow_failure: true

A manual job lands in the pipeline as a button; nothing runs until a person clicks it. What allow_failure decides is whether the rest of the pipeline waits for that click. With allow_failure: true, the pipeline continues past the manual job and can finish green without anyone pressing anything: an opt-in action, a soft gate. With allow_failure: false, the manual job blocks: jobs in later stages sit and wait until someone runs it and it succeeds. That’s an approval gate, and it’s what you want in front of a production deploy.

Both are legitimate; you just have to know which one you’re writing, because inside rules the default is allow_failure: false. Outside rules, a plain when: manual job defaults to allow_failure: true. The same keyword flips its default depending on where it appears, which is exactly the kind of detail worth checking in the docs instead of trusting muscle memory. Two more values exist, delayed and on_failure; you’ll need them rarely, and the reference covers them.

The rule that matched everything

For a long time we had a heavy job with an escape hatch: write [skip ci] in the commit message and a rule keeps the job out of the pipeline. The rule looked like this:

expensive-suite:
  stage: test
  script: ./run-suite.sh
  rules:
    - if: $CI_COMMIT_MESSAGE =~ /[skip ci]/
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

if regexes use RE2 syntax, and in RE2, as in every regex dialect, unescaped square brackets form a character class. /[skip ci]/ matches any commit message containing at least one of the characters s, k, i, p, c, or a space — which is nearly every commit message ever written.

So the rule matched almost everything, when: never fired on almost every pipeline, and the job quietly stopped running for a long time. Nobody noticed, because a job excluded by rules looks identical to a rule doing its intended work: no error, no red X, no log line, just an absence in a list nobody was counting. A broken rule doesn’t throw; it changes which jobs exist.

The fix was five characters: escape the brackets, /\[skip ci\]/. The diagnosis was the expensive part: someone eventually asked why the suite had no recent runs. Test rules against real values before trusting them. Take actual commit messages and branch names from recent pipelines and check by hand, or with a throwaway job that just echoes the variables, which rule each one would hit.

Where this leaves you

Every job now justifies its presence: deploys only where they belong, expensive suites only when relevant files moved, and a human in the loop wherever a click should decide. But rules operate job by job, and some problems live above the jobs: push a branch that has an open merge request and GitLab can happily create two near-identical pipelines for the same commit, each one’s rules working exactly as written. Controlling that takes pipeline-level rules, workflow: rules, and choosing a pipeline type; that’s part 6.

References