GitLab CI/CD From Zero to Pipeline Architect: Part 4
In the last posts, you learned how to:
Rules, Workflow & Pipelines That Think For Themselves
- Build proper multi-stage pipelines
- Use variables, artifacts, and shared templates
Now we’ll teach your pipelines to decide things on their own:
- “Is this just a docs change? Then don’t waste money running heavy tests.”
- “Is this a merge request? Then run quick checks only.”
- “Is this main? Then do the full, serious pipeline.”
This is where rules: and workflow:rules come in.
By the end of this post, you’ll be able to:
- Use rules: instead of old only/except
- Decide when a pipeline is created at all with workflow:rules
- Build different behaviors for MR vs main vs tags
- Use manual jobs as gates (e.g., prod deploy)
- Skip heavy jobs on docs-only changes
- Add advanced logic with rules:changes, CI_PIPELINE_SOURCE, and variables
1. Two levels of decision making: workflow vs job rules
There are really two questions GitLab asks:
- Should we create a pipeline at all?
→ Controlled by workflow:rules (pipeline-level) - If a pipeline exists, which jobs should run and how?
→ Controlled by rules: inside jobs (job-level)
Think of it like this:
- First, a bouncer at the door (workflow) decides if the event even happens.
- Then, a schedule inside (job rules) decides which performances actually run.
2. rules: vs only/except – why rules: is the modern way
Older GitLab examples use only: and except::
job:
script: npm test
only:
- main
except:
- tagsThis works, but it’s limited.
Why rules: is better
- Can check many things, not just branch names:
CI_PIPELINE_SOURCE (push, MR, schedule, web, trigger)
CI_COMMIT_BRANCH, CI_COMMIT_TAG
Custom variables (like RUN_HEAVY_TESTS) - Can set different behaviors:
when: manual, when: never, when: delayed
allow_failure: true/false - You can have many conditions in priority order.
Basic form:
job:
script: echo "Hello"
rules:
- if: <condition>
when: <on_success|manual|never|always|delayed>
allow_failure: <true|false> # optionalGitLab reads the rules top to bottom and picks the first one that matches.
3. Common rules: patterns (MR, main, tags)
3.1 Run job on merge requests only
test_mr:
stage: test
script: npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
- when: never- Only runs in MR pipelines.
- Won’t run on plain branch pushes.
3.2 Run job on main only
test_main:
stage: test
script: npm test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never3.3 Run job on tags (for releases)
publish_release:
stage: deploy
script: ./publish_release.sh
rules:
- if: '$CI_COMMIT_TAG'
when: on_success
- when: neverHere, CI_COMMIT_TAG is set whenever the pipeline is for a tag.
3.4 Mix MR + main logic
test_all:
stage: test
script: npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success # run on MR
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success # run on main
- when: never # block all other cases4. workflow:rules – controlling whether a pipeline exists
rules: lives inside jobs.
workflow:rules lives at the top level and controls the pipeline itself.
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: always
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
- when: neverThis means:
- Create pipelines for:
main pushes
merge requests - Do NOT create pipelines for:
random feature branches
tags, schedules, etc.
So you save CI minutes and keep things clean.
5. Skipping pipelines for docs-only changes
Now let’s do something smarter: don’t run the full pipeline for docs-only changes.
5.1 Docs-only vs full pipeline with workflow:rules + changes:
workflow:
rules:
# 1) Docs-only change → create a "light" pipeline
- changes:
paths:
- "docs/**"
- "README.md"
when: always
# 2) Any other change → full pipeline
- when: alwaysThis always creates a pipeline, but lets you distinguish docs-only later with job rules: and changes:.
5.2 Light jobs for docs-only, full jobs for code
docs_check:
stage: validate
script: echo "Checking docs formatting..."
rules:
- changes:
paths:
- "docs/**"
- "README.md"
when: on_success
- when: never
heavy_tests:
stage: test
script: npm test
rules:
# Only run heavy tests when src/** files change
- changes:
paths:
- "src/**"
when: on_success
- when: neverEffect:
- If you change only docs → docs_check runs, heavy_tests is skipped.
- If you change app code → heavy_tests runs; docs_check might not (if you want you can add it with more rules).
6. Different pipeline types: MR vs main (quick vs full)
A very common pattern in real teams:
- MR pipelines → fast feedback (lint, unit tests, maybe basic static checks)
- main pipelines → full checks (security scans, build & push images, deploy to staging, etc.)
6.1 Example: quick pipeline for MRs, full pipeline for main
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always # create MR pipelines
- if: '$CI_COMMIT_BRANCH == "main"'
when: always # create pipelines for main
- when: never # ignore other branchesNow define jobs:
lint:
stage: validate
script: npm run lint
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
unit_tests:
stage: test
script: npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
security_scan:
stage: test
script: ./scan.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
build_image:
stage: build
script: ./build_image.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: neverResult:
- MR pipelines:
lint, unit_tests - main pipelines:
lint, unit_tests, security_scan, build_image
Same .gitlab-ci.yml → two styles of pipeline.
7. Manual jobs and approvals (blocking vs optional)
Manual jobs are perfect for deployments or steps that must be triggered by a human.
There are two types:
- Optional manual jobs — they don’t block other jobs, failure doesn’t fail the pipeline.
- Blocking manual jobs — they block the stage/pipeline until run or set to allow_failure.
According to GitLab docs:
- Jobs with when: manual outside rules: default to optional (allow_failure: true). (Gitlab Docs)
- Jobs with when: manual inside rules: default to blocking (allow_failure: false).
7.1 Optional manual job (non-blocking)
deploy_preview:
stage: deploy
script: ./deploy_preview.sh
when: manual # optional by default (allow_failure: true)- Pipeline doesn’t wait for it.
- If it fails, the pipeline can still be green.
7.2 Blocking manual job (used as a gate)
Use rules: and make it clear:
deploy_prod:
stage: deploy
script: ./deploy_prod.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
allow_failure: false
- when: never- Pipeline gets into a blocked state at the deploy stage until someone presses play.
- If it fails, pipeline is red.
This is exactly what you want for production deployments.
8. Conditional behavior with rules + variables
You can make jobs even smarter with variables.
8.1 Heavy tests only on main or when a flag is set
heavy_tests:
stage: test
script: npm run test:heavy
rules:
# Run automatically on main
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
# Or if someone sets RUN_HEAVY_TESTS=true when triggering pipeline
- if: '$RUN_HEAVY_TESTS == "true"'
when: on_success
# Otherwise skip
- when: neverUsage:
- On MRs: you usually run light tests only.
- If you want heavy tests for a specific MR, you can Run pipeline with variable RUN_HEAVY_TESTS=true.
8.2 Changing allow_failure based on branch
Sometimes you want:
- On feature branches: allow a job to fail but still see results.
- On main: treat failures as hard failures.
security_scan:
stage: test
script: ./scan.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
allow_failure: false # strict in main
- if: '$CI_COMMIT_BRANCH =~ /^feature\//'
when: on_success
allow_failure: true # soft in feature/*
- when: never9. Building a short critical path + optional extras
Real pipelines have a critical path:
- lint → unit tests → package/build
And extra stuff:
- long-running e2e tests
- heavy security scans
- docs previews
- performance benchmarks
Use rules: + manual jobs to build that shape.
Example pattern
lint:
stage: validate
script: npm run lint
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
unit_tests:
stage: test
script: npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: on_success
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
e2e_tests:
stage: test
script: ./run_e2e.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
allow_failure: true # optional gate
- when: never
perf_tests:
stage: test
script: ./run_perf.sh
rules:
- if: '$RUN_PERF == "true"'
when: manual
allow_failure: true
- when: never- MR: lint, unit_tests → fast.
- main: lint, unit_tests; e2e_tests appears as manual.
- On demand: run perf_tests if needed.
10. Advanced patterns (for when you’re becoming the CI architect)
Here are some extras to make your pipelines really “think”.
10.1 Avoiding duplicate pipelines (MR + push)
By default, a push to a branch with an open MR can trigger:
- a branch pipeline
- an MR pipeline
To avoid duplicates, use workflow:rules and ignore push pipelines when an MR exists:
workflow:
rules:
# Prefer merge_request_event pipelines
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
# For regular branch pushes (no MR yet)
- if: '$CI_PIPELINE_SOURCE == "push"'
when: always
# Ignore everything else
- when: neverThen inside jobs, you can use if: '$CI_PIPELINE_SOURCE == "merge_request_event"' to run MR-specific logic.
10.2 rules:changes:compare_to – skip empty branches
GitLab now supports compare_to to skip jobs when a branch has no real diff vs default.
light_job:
stage: test
script: echo "Runs only when branch has changes vs main"
rules:
- if: '$CI_COMMIT_BRANCH'
changes:
compare_to: 'refs/heads/main'
paths:
- '**/*'
when: on_success
- when: never- If the branch has no changes compared to main, the job won’t run.
10.3 Combining workflow:rules + rules:changes for “mode switching”
Full example:
- docs-only → light docs pipeline
- code change → full pipeline
workflow:
rules:
# docs-only changes → still create a pipeline
- changes:
paths:
- "docs/**"
- "README.md"
when: always
# any other change → full pipeline
- when: always
docs_lint:
stage: validate
script: ./check_docs.sh
rules:
- changes:
paths:
- "docs/**"
- "README.md"
when: on_success
- when: never
code_tests:
stage: test
script: npm test
rules:
- changes:
paths:
- "src/**"
when: on_success
- when: never
build_image:
stage: build
script: ./build_image.sh
rules:
- changes:
paths:
- "src/**"
when: on_success
- when: neverThis gives you a mode switch: “docs mode” vs “code mode”, all from one file.
10.4 “Pipeline types” via variables
You can let users choose the kind of pipeline they want when they click Run pipeline:
workflow:
rules:
- if: '$PIPELINE_TYPE == "full"'
when: always
- if: '$PIPELINE_TYPE == "quick"'
when: always
- when: always # default if not setThen in jobs:
heavy_tests:
stage: test
script: ./heavy_tests.sh
rules:
- if: '$PIPELINE_TYPE == "full"'
when: on_success
- when: neverNow:
- Devs can usually run PIPELINE_TYPE=quick for fast feedback.
- Before merging or tagging, they can run PIPELINE_TYPE=full.
11. Best practices summary
- Prefer rules: over only/except for new configs.
- Use workflow:rules to decide if a pipeline should run at all.
- Separate behavior for:
1. MR vs main
2. code changes vs docs-only changes
3. quick vs full pipelines (via variables) - Use blocking manual jobs for:
1. production deploys
2. critical data migrations - Use optional manual jobs for:
1. e2e, perf, or experiments - Build a short critical path (lint + unit tests) that always runs quickly.
- Add extra jobs (security, e2e, perf) as:
1. branch-specific
2. manual
3. change-based (rules:changes)
Conclusion
By mastering rules: and workflow:rules, you’ve moved from simple “run everything on every push” pipelines to smart pipelines that react to context. Your GitLab CI is no longer a dumb script runner – it now understands where the change came from (MR, branch, tag), what changed (docs vs code), and how serious the action should be (quick feedback vs full, gated deploy).
You now have the tools to:
- Shape different behaviors for MRs, main, and tags.
- Skip expensive jobs for docs-only changes with rules:changes.
- Protect production with manual gates and controlled approvals.
- Turn heavy checks on or off using variables when you need them.
With these patterns in place, your pipelines stay fast for everyday work, but strict and safe when it really matters — exactly how a professional CI/CD system should behave.
In the next post, we can go deep on:
Where you’ll master artifacts, cache, needs:, parallel, and resource_group to design pipelines that stay fast, reproducible, and reliable even as your codebase and teams grow. This is where your pipelines go from smart to seriously high-performance.
Comments
Questions, corrections, war stories — all welcome. Sign in with GitHub to join the discussion.