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:
true, ifwhen: manualis written at the job's top level;false, ifwhen: manualsits insiderules, like ours;falsein every other case.
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
- Rules are in the wrong order. A general rule above a specific
when: nevercatches every case, and the exception never fires. when: manualat the job level. The pipeline doesn't block, and the following stages run without a deploy. You needwhen: manualinside a rule or an explicitallow_failure: false.onlyleft next to newrules. The config isn't accepted at all, and the pipeline isn't created for any branch.!=for "every branch except the default one". In a tag pipeline or a merge request pipeline, there's noCI_COMMIT_BRANCHvariable, and an undefined variable isn't equal tomain. The condition$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCHis true there too, and the draft deploy runs on the tag. If that's not what you want, add a check that the branch exists at all:$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH.
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.
git -C work commit -am "...": the runner, like GitLab, only reads what's committed.git -C work checkout -b feature-xandgit -C work checkout main: switch branches to see the pipeline of another branch.bash run-pipeline.sh: run the pipeline on the current branch's latest commit.bash run-pipeline.sh --play deploy-prod: the same pipeline with the manual job's button pressed.ruleswithif,when: manual,when: never, the comparisons==,!=,=~.
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.
Lesson marked as done.
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.
Tell the agent: use MCP izi, give me lesson cicd-practice-09-rules
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress