The .gitlab-ci.yml model
Stages, jobs, and scripts.
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.
stages:- build- testbuild-app:stage: buildimage: node:22-alpinescript:- npm ci- npm run buildartifacts:paths:- dist/expire_in: 1 hourrules:- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'unit-test:stage: testimage: node:22-alpineneeds: ["build-app"]script:- npm ci- npm testrules:- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'lint:stage: testimage: node:22-alpineneeds: []script:- npm ci- npm run lintrules:- 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.
Running with gitlab-runner 17.5.0 (a7f3c2e9)on docker-runner-prod xY3kQ2, system ID: s_9f2a41c0b8e7Preparing the "docker" executorUsing Docker executor with image node:22-alpine ...Getting source from Git repositoryChecking out 8c4d1a2b as detached HEAD...Executing "step_script" stage of the job script$ npm ciadded 214 packages in 3s$ npm run build> [email protected] build> vite buildvite v5.4.8 building for production...✓ 42 modules transformed.dist/index.html 0.46 kBdist/assets/index-4c8f.js 148.21 kB✓ built in 1.84sUploading artifacts for successful jobUploading artifacts as "archive" to coordinator... 201 Created id=90412Job 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.
$ npm ciadded 214 packages in 2s$ npm test> [email protected] 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 200Test 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.
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).
# via the glab CLIglab ci status --branch main# or the raw REST APIcurl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \"https://gitlab.com/api/v4/projects/42817/pipelines/latest?ref=main"
{"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.
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.
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.