Mastering dynamic control flow with rules and workflow. Comparing the legacy only and except with modern rules, composing if, changes, and exists conditions, using the when parameter, and preventing duplicate pipelines with workflow-level rules.

In episode 3 you had runners ready to execute jobs. Now the question is: when should a job run? A pipeline that runs on every push without control wastes resources — and can even be dangerous. Imagine a deploy job that runs just because a README file changed, or wasteful duplicate pipelines every time you push to a branch that already has an open merge request.
This episode covers dynamic control flow: how to control when a job joins a pipeline and when the whole pipeline is created, using rules and workflow.
only/except vs Modern rulesFor a long time GitLab provided only and except to control when jobs run. Both are simple: only: [main] means a job only runs on the main branch, and except: [tags] means it doesn't run on tags. But their limitations are real: combining multiple conditions is difficult, they can't be used at the workflow level, and evaluation is rigid because it only matches static value lists.
rules arrives as their replacement — more powerful and flexible:
| Aspect | only / except | rules |
|---|---|---|
| Level | Jobs only | Jobs and workflow |
| Condition combination | Limited | Flexible: if, changes, exists |
when control | Simple | on_success, on_failure, always, manual, never |
| Evaluation | Static value lists | Dynamic variable expressions |
| Status | Legacy (deprecated) | Recommended |
Warning
Don't write new pipelines with only/except — both are legacy. GitLab recommends rules for all new pipelines, and old pipelines using them will trigger deprecation warnings.
rules Basic Syntaxrules is a list of conditions evaluated sequentially from top to bottom. The first matching condition determines the job's fate — if none match, the default is that the job doesn't join the pipeline. Four main conditions:
if — a condition based on predefined variables, e.g. branch name or pipeline source.changes — runs only if certain files change.exists — runs if a certain file exists in the repository.when — the final decision: run, fail, manual, always, or not at all.if Condition — Variable-BasedThe if condition compares predefined variables against specific values. Classic example: a deploy job only appears on the main branch:
deploy_prod:
stage: deploy
script:
- ./deploy.sh
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manualHere the deploy_prod job only appears when the branch being processed is main, and it runs only after being clicked manually. The variable $CI_COMMIT_BRANCH holds the name of the branch being processed — one of the predefined variables we'll cover thoroughly in episode 5. Also note when: manual: this rule adds a layer of control, not just "run or not".
changes Condition — Run Only If Files ChangedThe changes condition checks which files changed in the commit or merge request:
backend_test:
stage: test
script:
- npm test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- src/**/*The backend_test job only joins the pipeline if the pipeline comes from a merge request event and there are changes inside the src folder. This is the best way to trim pipeline time in a monorepo: a README change won't trigger backend tests.
exists Condition — Run If the File ExistsThe exists condition checks for a file's presence in the repository, regardless of whether it changed:
deploy_helm:
stage: deploy
script:
- helm upgrade --install my-app ./chart
rules:
- exists:
- "**/Chart.yaml"The Helm job above is only created if the repository has a Chart.yaml file in any directory. This condition is perfect for monorepo pipelines that handle multiple technologies at once.
when ParameterThe when parameter determines the final decision after a condition matches:
| Value | Behavior |
|---|---|
on_success | Default — runs if all previous jobs succeeded |
on_failure | Runs only if a previous job failed (e.g. cleanup) |
always | Always runs, regardless of previous results |
manual | Runs after being clicked by a human (approval gate) |
never | Job isn't created at all |
The most common combination in production — an approval gate for deploys and a notification on failure:
deploy_prod:
stage: deploy
script:
- ./deploy.sh
when: manual
notify_failure:
stage: deploy
script:
- curl -X POST https://alerts.example.com/deploy-failed
when: on_failuredeploy_prod waits for a human click (approval gate), while notify_failure automatically runs only if a previous job failed — perfect for alerting to Slack or monitoring tools.
workflow: rules — Controlling the Entire PipelineAll of the above focuses on the job level. But sometimes you want to control whether a pipeline is created at all. Classic case: you open a merge request from a feature branch, then push to that branch again. GitLab will create two pipelines — one for the push to the branch, one for the merge request. This wastes resources and is confusing.
The solution is workflow: rules — rules at the pipeline level:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
- when: neverPipelines are only created for three cases: merge request pipelines, pushes to the default branch, and tags. All other cases — for instance a push to a regular feature branch — do not produce a pipeline, because the when: never rule at the end closes all non-matching conditions.
Tip
One common mistake: workflow: rules without a closing when: never still creates pipelines for non-matching cases — because GitLab's default is to create pipelines. Always end your workflow rule list with an explicit condition like when: never or when: always.
when: never closer in workflow. Without it, pipelines are still created outside the allowed cases, and duplicate pipelines are unavoidable.$CI_COMMIT_BRANCH == "main" won't match a branch named Main. Make sure the branch name is written exactly.In this episode 4, you've mastered GitLab CI/CD dynamic control flow:
only/except and modern rules, which is more flexible and recommended.if condition, changes for file changes, and exists for file existence.when parameter: on_success, on_failure, always, manual, and never.workflow: rules to control when an entire pipeline is created and to prevent duplicate pipelines.Now you can control when a pipeline runs. In episode 5 we'll cover the values that flow through it — variables, masks, and secret management: predefined variables, custom CI/CD variables, security flags (protected, masked, expand), and direct HashiCorp Vault integration inside .gitlab-ci.yml. See you in episode 5!