Sign in
← CI/CD: patterns
11 min · medium · Lesson 3 of 5

Pipeline patterns: gates, approvals, fan-out

Manual approvals, protected environments, parallel fan-out/fan-in branches, and conditional job runs.

Why this matters

A simple pipeline runs top to bottom in one chain. On a real project that's not enough: some steps need to run in parallel, some need a human "yes", and a deploy to prod shouldn't run from just any branch. There are also steps that aren't needed on every commit: a docs fix shouldn't rebuild the app image.

There are a few stable patterns for all this. Below they're explained using GitLab CI syntax; GitHub Actions calls the same ideas by similar names.

Concepts

Manual gate: human confirmation

A gate stops the pipeline and waits for a human to press a button. The typical case: ship to staging automatically, and to prod only after a manual confirmation.

deploy-prod:
  stage: deploy
  script:
    - ./deploy.sh production
  when: manual
  allow_failure: false      # without this line the job doesn't block the pipeline
  environment:
    name: production

when: manual turns a job into a button in the UI. Whether that button blocks the pipeline depends on allow_failure, and the default here is counterintuitive.

For a job with when: manual written at the top level, allow_failure defaults to true. GitLab treats such a job as optional: an unpressed manual job counts as successful for the next stages, the pipeline keeps going and turns green. That's not a gate, just an available action. The classic surprise: someone thinks they put a safeguard in front of prod, and the pipeline finishes successfully right past it.

To make it a real gate, set allow_failure: false explicitly. Then the pipeline sits in a waiting state (Blocked in the pipeline list) and won't move on until the button is pressed.

Inside rules: the default is reversed: there, when: manual comes with allow_failure: false, so it blocks right away.

deploy-prod:
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual        # here allow_failure defaults to false, this is a gate

It's easier not to keep this in your head, and just write allow_failure explicitly in both cases. A reader of the config can then see right away whether it's a gate or an optional button. You work through this default by hand in the lab for the lesson "rules: who runs and who waits for a button" in the CI/CD practice track.

Protected environments and approvals

Not just anyone can press the button. In GitLab an environment can be made protected: the project settings say who is allowed to deploy to production, for example only maintainers. On top of that you configure deployment approvals: a deploy waits until another person approves it. By default, whoever triggered the pipeline can't approve their own deploy. This way a deploy to prod goes through review, like code.

Both features are paid in GitLab (Premium and Ultimate). In GitHub Actions the same idea is called an environment with required reviewers: up to six reviewers, one approval is enough. On the free GitHub plan this only works in public repositories.

This is also where the "deploy only from a protected branch" rule belongs. The condition if: $CI_COMMIT_BRANCH == "main" from the example above removes the deploy job from the pipelines of every other branch, and even a maintainer won't see the button from a random branch. But that's a convenience, not protection: the rule lives in .gitlab-ci.yml, and anyone who can push to their own branch can change it there. Protection comes from project settings, which you can't change from a branch. On free GitLab that means protected branches and protected variables: their values, such as the prod access key, only reach pipelines on protected branches and tags. Without the key, a job from someone else's branch can't reach prod, no matter how its rules get rewritten.

Fan-out and fan-in

Fan-out means branching the pipeline into parallel jobs, fan-in means bringing them back together. The classic case: after a build, run unit tests, a linter and a dependency audit all at once, then one shared step that waits for all of them.

build unit-tests lint audit package fan-out · branched fan-in · joined
Three checks run at the same time and don't depend on each other, and package starts only once all three are green.

Parallel jobs don't depend on each other and run at the same time, so the stage takes as long as the slowest check, not their sum. The package step starts only once all three are green.

GitLab gives you two ways to do this. The first: stages. Jobs in one stage run in parallel, and jobs in the next stage start once all jobs of the previous stages have finished successfully. So fan-out is several jobs in one stage, and fan-in is a job in the next one.

The second way: the needs key. It sets who a job waits for, directly, without looking at stages. The job starts as soon as the jobs listed in needs are ready, and it downloads artifacts only from them:

lint-docs:
  stage: test
  needs: []                 # waits for nothing, starts right away
  script:
    - ./lint-docs.sh

publish-docs:
  stage: deploy
  needs: [build-docs, lint-docs]
  script:
    - ./publish.sh site/

With needs it's easy to get fan-in wrong. If you forget one of the branches in the list, the step starts without waiting for it. If you forget the job that produced the files you need, the step starts on time, but without its artifacts. More on needs and the dependency graph: the lesson "Anatomy of a pipeline" in the CI/CD basics track.

Conditional runs

Not everything needs to run every time. rules run a job by condition: only on a tag, only in an MR, only when certain files change. The last one is done with rules:changes:

lint-chart:
  rules:
    - changes:
        - charts/**/*       # build only if something in charts/ was touched

This way the pipeline doesn't waste time on what didn't change: a chart edit doesn't trigger an app image rebuild, and the other way around.

GitLab compares path patterns with Ruby's File.fnmatch function with the FNM_PATHNAME, FNM_DOTMATCH and FNM_EXTGLOB flags. That's the source of the rules people get wrong most often:

One more quirk: what it's compared against. In a branch pipeline, GitLab compares the commit with that branch's previous commit. In an MR pipeline, it compares with the target branch. And in a pipeline for a new branch, a tag, a schedule, or a manual run, changes is always considered matched, and the job runs. To set the comparison base explicitly, there's rules:changes:compare_to.

Common mistakes

What you'll need for the tasks

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 3 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-advanced-03-pipeline-patterns