Sign in
← CI/CD: hands-on
13 min · medium · Lesson 9 of 10

rules: what runs and what waits for a button

We split deploys by branch, put a manual gate in front of prod, and work out why rules replaced only/except.

We split deploys by branch, put a manual gate on prod, and work out why rules pushed out only/except.

Why this matters

So far the pipeline is the same everywhere: any branch does what main does. That's not how it goes in real work. Tests run everywhere, a draft deploy happens for branches, and prod gets touched deliberately, not automatically.

The lesson materials live in the course repo, same as in previous lessons (you set up the remote upstream in the lesson about variables):

git pull --no-rebase upstream main
cp lesson-09-rules/pipeline.yml .gitlab-ci.yml

Conditions on a job

deploy-review:
  stage: deploy
  script:
    - echo "Draft deploy for branch $CI_COMMIT_BRANCH"
  rules:
    - if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH

deploy-prod:
  stage: deploy
  script:
    - echo "Production deploy from $CI_COMMIT_BRANCH"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
      allow_failure: false

rules is a list of conditions checked top to bottom until the first match. If a rule matches, the job lands in the pipeline. If none match, the job simply isn't there, not even as "skipped".

CI_DEFAULT_BRANCH holds the name of the project's default branch. Comparing against it beats hardcoding "main": older projects sometimes have master as the default branch.

Check it on your own project. Push to main, then create a branch:

git checkout -b feature-x
git push -u origin feature-x

Result: the branch's pipeline is green and has deploy-review in it, while main's pipeline is in status Blocked and deploy-prod waits for a click.

when: manual and what Blocked means

when: manual turns a job into a button. The pipeline reaches it, stops, and waits for a human. In the pipeline list it's marked Blocked, not Failed: nothing broke, a decision is just needed next. Stages after a manual job won't start until it's run and finishes successfully.

The button lives on the pipeline page: a Run icon next to the job.

There's a subtlety with allow_failure that trips people up even in articles. There are three defaults:

In the example above, the line allow_failure: false changes nothing; it's there as self-documentation. But move when: manual out of rules to the job level, and you get a manual job with allow_failure: true. A pipeline like that doesn't wait for the button: it moves on and goes green, while the manual job just sits there unclicked. The following stages, say a release announcement, will run without any deploy at all. If when: manual sits at the job level, writing allow_failure: false is mandatory.

Conditions worth knowing

Besides the branch, rules can look at the trigger source, changed files, and variables:

  rules:
    # merge requests only
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    # only if the frontend changed
    - changes:
        - src/**/*
    # manual run with a variable
    - if: $DEPLOY_TARGET == "staging"

if supports == and !=, the logical && and ||, and parentheses. =~ and !~ compare against a regular expression: $CI_COMMIT_BRANCH =~ /^release-/ is true for release-1.2 and false for main. The ^ anchor matters, otherwise hotfix-release-2 would match too. Just $CI_COMMIT_BRANCH with no comparison is true whenever the variable is set and not empty.

changes saves real minutes in monorepos: a README fix shouldn't trigger a build and tests for the whole project. One catch: this rule only makes sense for a branch pipeline and a merge request. Where there's no push event (scheduled, by tag, manual), changes is always true, and the job runs even if nothing changed. Same thing on a new branch's first pipeline. To compare against a specific branch, there's rules:changes:compare_to.

An explicit when: never is also useful for cutting out a special case ahead of a general rule:

  rules:
    - if: $CI_COMMIT_BRANCH == "wip"
      when: never
    - if: $CI_COMMIT_BRANCH

Order matters: the first matching rule wins, the rest aren't even looked at. Swap these two rules, and when: never never fires: if: $CI_COMMIT_BRANCH catches any branch, wip included.

only and except: legacy

Older configs and tutorials use a different syntax:

deploy:
  script:
    - echo "deploy"
  only:
    - main

only/except are officially marked deprecated: they still work for compatibility, but may be removed in a future major release. Mixing them with rules in the same job isn't allowed. GitLab rejects the config:

jobs:deploy config key may not be used with `rules`: only

Contrary to popular belief, they do support conditions on variables and changed files (only:variables, only:changes). What they lack is the "condition plus action" pairing: you can't say when: manual or allow_failure inside a rule, and that's exactly what's usually needed. That's why a manual deploy in old configs looks like only: [main] plus when: manual at the job level. And it gets allow_failure: true by default, with all the consequences from the section above.

New configs are written with rules. Rewrite the old ones whenever you're touching that job anyway. When converting, move when: manual inside the rule, don't leave it at the job level next to rules.

If the job didn't show up

The most common reason is that no rule matched. That's not a bug, it's the default behavior. Check the branch's actual name: CI_DEFAULT_BRANCH is main on new projects and sometimes master on older ones.

Second most common: merge_request_event in the rule while you're pushing to a branch with no open merge request. A branch pipeline and an MR pipeline are different events.

To see how GitLab understood the config, check Build → Pipeline editor → the Full configuration tab: it expands include, extends, and anchors. Keep in mind the conditions there are evaluated for a hypothetical push to the default branch, not the one you're currently on.

Common mistakes

What to remember

rules are checked top to bottom until the first match; nothing matches, and the job isn't created at all.

when: manual turns a job into a button, and the pipeline gets status Blocked if the manual job isn't allowed to be skipped.

Manual jobs default allow_failure to true at the job level and false inside rules; a production deploy needs false.

changes and variable conditions save minutes and keep you from running things you don't need.

only/except are deprecated and don't mix with rules in the same job.

What you'll need for the tasks

In the labs, the pipeline runs through a training runner on your laptop. Like GitLab, it first evaluates rules and decides which jobs go into the pipeline, then runs the jobs by stage, each in a clean container. The runner takes the pipeline's branch from the current branch of the work/ repository; the project's default branch is main. It doesn't run manual jobs itself; the Run button is replaced by the --play flag.

Next we put together a breakdown of antipatterns: what breaks most often in a pipeline and what it looks like in practice.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson cicd-practice-09-rules