← All articles
CI/CDDec 14, 202510 min read

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:

  1. Should we create a pipeline at all?
    → Controlled by workflow:rules (pipeline-level)
  2. 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:
- tags

This 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> # optional

GitLab 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: never

3.3 Run job on tags (for releases)

publish_release:
stage: deploy
script: ./publish_release.sh
rules:
- if: '$CI_COMMIT_TAG'
when: on_success
- when: never

Here, 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 cases

4. 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: never

This 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: always

This 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: never

Effect:

  • 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 branches

Now 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: never

Result:

  • 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: never

Usage:

  • 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: never

9. 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: never

Then 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: never

This 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 set

Then in jobs:

heavy_tests:
stage: test
script: ./heavy_tests.sh
rules:
- if: '$PIPELINE_TYPE == "full"'
when: on_success
- when: never

Now:

  • 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:

Part 5 — Fast & Reliable Pipelines: Artifacts, Cache & DAGs
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.
MK
Mohankrishna PodileDevOps Engineer & Cloud Architect · Irving, Texas

Comments

Questions, corrections, war stories — all welcome. Sign in with GitHub to join the discussion.