CoursesSecure CI/CD with GitLabThe .gitlab-ci.yml model

The .gitlab-ci.yml model

Stages, jobs, and scripts.

Beginner14 min · lesson 1 of 17

Push a commit to a GitLab project and a pipeline appears within seconds. Boxes go grey, then green, or red. Nobody clicked a button. Nobody set up a build server through a web form. GitLab did one thing: it read a single file at the root of your repository, .gitlab-ci.yml, and ran the work that file describes. Treat that file as the project's recipe card. It holds all of the project's CI/CD (continuous integration and continuous delivery, meaning code gets built, tested and shipped automatically on every change). It is written in YAML, a plain-text settings format where indentation does the work brackets do elsewhere. It sits next to your code, it gets reviewed in merge requests like any other change, and GitLab re-reads it on every push, merge request, tag and schedule. Learn its three nouns, stages, jobs and scripts, and you can read, review and secure almost any GitLab pipeline.

Three nouns: stages, jobs, and scripts

A restaurant kitchen already works this way. Prep finishes before anyone cooks, cooking finishes before anyone plates, and each station has its own list of instructions taped to the wall. GitLab uses the same three ideas under different names. A job is one named unit of work that runs a list of shell commands, its script, inside a container image you choose. A stage is a named group of jobs; stages run in a fixed order, and every job in one stage has to finish before the next stage starts. The script is ordinary shell, the same lines you would type into a terminal, run one after another. Everything else in the file (caching, artifacts, rules, environments) is detail hanging off those three ideas. Here is a working two-stage pipeline for a Node app.

.gitlab-ci.yml
stages:
- build
- test
build-app:
stage: build
image: node:22-alpine
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
unit-test:
stage: test
image: node:22-alpine
needs: ["build-app"]
script:
- npm ci
- npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
lint:
stage: test
image: node:22-alpine
needs: []
script:
- npm ci
- npm run lint
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

Read it top to bottom. stages declares two phases in order, build then test. Each job names the stage it belongs to. build-app runs inside a node:22-alpine container, installs dependencies with npm ci, builds, and hands its dist/ folder to later jobs as an artifact. An artifact is a set of files GitLab uploads when a job ends and re-downloads into the jobs that come after it, like a parcel left out for the next shift. rules:if decides whether a job is added to the pipeline at all, and here that means merge-request events and pushes to the default branch, nothing else. rules is the modern replacement for only/except, which GitLab now steers you away from. The needs keys matter as much. unit-test needs build-app, while lint declares needs: [], and an empty needs list means depend on nothing, start immediately.

How a job runs, and how it fails

When the pipeline starts, GitLab hands each eligible job to a runner. A runner is the agent that does the actual work, the way a courier does the actual driving while the dispatcher only hands out the addresses. Runners are the whole of the next lesson. The runner pulls the image, checks out your commit, and runs the script commands one at a time. After each command it reads the exit code, the small number every command hands back when it finishes: zero means it worked, anything else means it failed. The first non-zero exit kills the job on the spot. Later script lines never run, and the job goes red. A failed job fails its stage. A failed stage stops the pipeline, so no later stage begins. That one rule, non-zero exit equals failure, is the entire gate. It is why a test runner, a linter or a security scanner can block a merge by exiting 1. Here is the runner log from a passing build-app job.

build-app — job log (success)
Running with gitlab-runner 17.5.0 (a7f3c2e9)
on docker-runner-prod xY3kQ2, system ID: s_9f2a41c0b8e7
Preparing the "docker" executor
Using Docker executor with image node:22-alpine ...
Getting source from Git repository
Checking out 8c4d1a2b as detached HEAD...
Executing "step_script" stage of the job script
$ npm ci
added 214 packages in 3s
$ npm run build
> vite build
vite v5.4.8 building for production...
✓ 42 modules transformed.
dist/index.html 0.46 kB
dist/assets/index-4c8f.js 148.21 kB
✓ built in 1.84s
Uploading artifacts for successful job
Uploading artifacts as "archive" to coordinator... 201 Created id=90412
Job succeeded

Every command printed its output, nothing came back non-zero, and the runner signs off with Job succeeded. Now watch the same test stage when a test breaks.

unit-test — job log (failure)
$ npm ci
added 214 packages in 2s
$ npm test
> vitest run
✓ src/sum.test.js (3 tests) 4ms
✗ src/auth.test.js (2 tests | 1 failed) 8ms
× rejects expired token
→ expected 401 to be 200
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 4 passed (5)
ERROR: Job failed: exit code 1

vitest found a broken test and exited 1. The runner prints ERROR: Job failed: exit code 1, paints unit-test red, and because unit-test sits in the test stage, nothing scheduled after test runs. You wrote no if/then logic to make that happen. The exit code did it for you. A single job can opt out with allow_failure: true, which lets the pipeline carry on while that job shows amber. Reach for it rarely, and never for a security gate whose verdict you actually rely on.

Stages are the simple model; needs is the dependency graph

Pure stage ordering is easy to hold in your head and wasteful in practice, like a school trip where the whole class waits at every checkpoint for the slowest walker. lint has no reason to wait for build-app, yet by default every job in the test stage waits for the entire build stage to end. needs breaks that. It declares an explicit dependency graph, a DAG (directed acyclic graph: the arrows only ever point forward, and no path loops back to where it started), so a job starts the moment the jobs it names are done, stage boundaries ignored. lint with needs: [] starts at once. unit-test with needs: [build-app] starts as soon as the build finishes, running alongside lint. On a large pipeline that saves a serious amount of wall-clock time. The cost is that needs makes ordering implicit and easy to get subtly wrong, and a job may only need work that runs no later than itself, because GitLab rejects a cycle. Stages give you a coarse guarantee. needs gives you speed and precision on top.

One push, one pipeline
1Push / MR
GitLab reads .gitlab-ci.yml, evaluates rules
2build stage
build-app in node:22-alpine, emits dist/
3test stage
lint (needs:[]) ∥ unit-test (needs:build-app)
4Rollup
any non-zero exit → stage fails → pipeline red
needs turns the straight stage order into a dependency graph: lint starts immediately, unit-test waits only for build-app.

Read the result without opening the browser

A pipeline's status is a colour in the UI (user interface, the pipeline page you look at), and it is also a value you can ask for, which is what lets a deploy gate or an outside system act on it instead of trusting somebody's glance at a screen. GitLab exposes every pipeline over its REST API, an application programming interface you talk to with ordinary web requests, and the glab command-line tool wraps that API in something shorter to type. Both hand back JSON (JavaScript Object Notation, a plain-text data format that programs can read).

shell — query the latest pipeline
# via the glab CLI
glab ci status --branch main
# or the raw REST API
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/42817/pipelines/latest?ref=main"
GET /pipelines/latest — response
{
"id": 1180422,
"iid": 87,
"project_id": 42817,
"sha": "8c4d1a2b6f0e9d3c...",
"ref": "main",
"status": "success",
"source": "push",
"created_at": "2026-07-14T09:12:04.117Z",
"web_url": "https://gitlab.com/acme/app/-/pipelines/1180422"
}

status is the rollup for the whole run: success only when no job failed apart from ones marked allow_failure. A deploy gate, an Argo CD sync (Argo CD deploys whatever your Git repository currently says), or a compliance check reads that one field instead of a screenshot of the pipeline graph. Gating on machine-readable evidence like this response, rather than on a person's glance, is the thread that runs through the rest of this course.

Green can mean your scan never ran at all
A job whose rules:if never matches is dropped from the pipeline without a word. It is not failed. It does not exist for that run. So if your SAST (Static Application Security Testing, the scanner that reads your source code looking for vulnerabilities) job or your secret-detection job carries a rules condition that misses the branch or the event that actually shipped, the pipeline goes green having never run the scan once. status: success proves that nothing failed. It does not prove that everything ran. Assert that the security jobs you expect are present in the pipeline's job list, not only that the rollup is green.

All of this leans on one actor the lesson kept waving past: the runner that pulls the image, checks out the commit, and executes each script line. Where that runner lives, what network and secrets it can reach, and whether you would trust it with a merge request from a stranger's fork is where CI security actually starts. That is the next lesson.

Quick check
01In that pipeline, unit-test finishes with exit code 1 while lint finishes with 0. What happens next, and why?
Incorrect — No. Jobs fail one at a time. lint exited 0, so lint stays green and only unit-test goes red.
Correct — A non-zero exit fails the job, the failed job fails its stage, and a failed stage stops the pipeline from moving on.
Incorrect — No. The rollup reads success only when every job without allow_failure passes. One red job turns the whole pipeline red.
Incorrect — No. Nothing retries by itself. A retry happens only when you put retry: on the job.
02The warning above says a green pipeline can hide a security scan that never ran. How can a pipeline report status success when the SAST (Static Application Security Testing) job never executed?
Correct — A dropped job is not a failed job. It is absent from that run, so the rollup can be green while the scan never happened.
Incorrect — A skipped job is not recorded as passed. It is missing from the run, which is exactly why the green rollup misleads you.
Incorrect — Nothing here says SAST defaults to allow_failure. The real hole is a job excluded by its own rules, not a soft-fail flag.
Incorrect — The rollup is worked out after every scheduled job has finished. The problem is a job that was never scheduled in the first place.
03build-app sits in the build stage, while lint (needs: []) and unit-test (needs: ["build-app"]) both sit in the test stage. On a merge-request pipeline, when does lint start?
Incorrect — needs: [] releases lint from stage ordering, so it waits for no other job in the test stage.
Incorrect — That is the default only for a job with no needs key. needs: [] deliberately overrides the wait at the stage boundary.
Correct — An empty needs list starts lint at once, ignoring stage boundaries, in parallel with build-app.
Incorrect — lint's rules include the merge_request_event condition, so it does run on a merge request. The question is only about timing.

Try this

Work through “Read the result without opening the browser” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: green can mean your scan never ran at all. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related