Reading and writing .gitlab-ci.yml
Intermediate GitLab UI
Why this matters
The file is the pipeline. Reading it tells you exactly what will be checked, which lets you run the same checks locally; writing a small piece of it is a realistic and welcome contribution, because documentation checks are usually nobody's job.
The real file
This site's own .gitlab-ci.yml, entire. It has three jobs: one that checks the committed HTML is up to date, one that checks every link, and one that publishes the site.
stages:
- check
- deploy
default:
image: python:3.13-slim
build-is-current:
stage: check
script:
- pip install --quiet markdown
- python tools/build.py
- git diff --exit-code || (echo "The committed HTML is out of date. Run 'python tools/build.py' and commit the result." && exit 1)
links:
stage: check
script:
- pip install --quiet markdown
- python tools/build.py
- python tools/check-links.py
pages:
stage: deploy
script:
- pip install --quiet markdown
- python tools/build.py
- mkdir -p public
- cp -r index.html assets playground start reference labs [0-9]* public/
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHLine by line
stages lists the phases in order. Everything in check runs before anything in deploy, and if any check job fails, deploy never starts.
default: with image: sets the container every job starts from, unless a job overrides it. python:3.13-slim is a small official Python image; the slim variants save minutes of download time.
Each top-level name is a job. build-is-current, links and pages are the three here. The names are yours to choose, except that pages is special on GitLab: it is the job whose artifact gets published.
script is a list of shell commands, run in order in a fresh container. If any command exits non-zero, the job fails. These are ordinary commands you can run yourself, which is the most useful thing to know about the whole file.
artifacts: paths: keeps files after the job ends. For pages, the public folder is what GitLab serves.
rules: decide whether a job runs at all. Here, pages runs only when the branch is the project's default branch, so a merge request runs the checks but never publishes.
The clever line is in build-is-current. The site's HTML is committed, so a contributor could edit a lesson and forget to rebuild. The job rebuilds and then asks Git whether anything changed:
$ python tools/build.py
$ git diff --exit-codegit diff --exit-code exits 1 when there are differences, which fails the job with a message telling the contributor exactly what to do.
The keywords you will meet elsewhere
| Keyword | Does |
|---|---|
stage |
Which phase the job belongs to |
image |
The container this job runs in |
script |
The commands |
before_script / after_script |
Commands run around every job's script |
rules / only / except |
When the job runs; rules is the modern form |
artifacts |
Files to keep |
cache |
Files to reuse between runs, such as pip downloads |
needs |
Run this job as soon as named jobs finish, ignoring stage order |
variables |
Values available to the job |
when: manual |
The job waits for a person to press play |
allow_failure: true |
The job can fail without failing the pipeline |
include |
Pull in another YAML file, often a shared template |
environment |
Record this job as a deployment (lesson 14.6) |
Adding a check of your own
A documentation check is the natural first contribution. Adding a job is four lines:
spelling:
stage: check
image: node:20
script:
- npx --yes cspell "docs/**/*.md"
allow_failure: trueTwo deliberate choices there. The job overrides the default image, because the tool is a Node package. And allow_failure: true means it reports without blocking, which is the polite way to introduce a new check to a team: everyone sees the result for a few weeks, the existing problems get cleaned up, and only then does it become blocking.
Testing a change before pushing
Three levels, in increasing order of effort:
- Run the
scriptlines yourself. They are ordinary commands. - Use the platform's linter: Build → Pipeline editor → Validate, which checks the YAML and the structure without running anything.
- Push to a branch. A pipeline change is like any other change: it goes on a branch and through a merge request, and the pipeline that runs on that branch is the new one.
The pipeline editor is worth knowing about: it catches indentation mistakes, which are the most common YAML error and the most annoying to debug from a failed run.
How to do it
Read the file, then run what it runs:
$ cat .gitlab-ci.yml
$ python tools/build.py
$ python tools/check-links.pyIf those pass locally, the equivalent jobs will almost certainly pass on the runner. The remaining differences are versions and uncommitted files (lesson 14.2).
The GitLab Workflow extension validates .gitlab-ci.yml as you type and offers completion for keywords, which prevents most structural mistakes.
The Ultimate edition ships a schema for .gitlab-ci.yml, giving completion and inline errors. In any edition, the YAML editor at least catches indentation problems.
Build → Pipeline editor shows the file with a Validate button, a visualisation of the stages and jobs, and the full list of keywords in its help. It commits through the ordinary merge request flow.
The equivalent file is a workflow under .github/workflows/, with jobs and steps instead of stages and scripts. See lesson 14.5.
Common mistakes
- Tabs, or wrong indentation. YAML rejects tabs; nesting decides meaning.
- Assuming a job runs everywhere. Check its
rules. - A new check added as blocking, which stops everyone's work until the backlog of existing problems is cleared.
- Forgetting the job needs its tools installed. A fresh container has nothing but the image.
- Editing the file directly on the default branch, which is both risky and usually blocked.
- Expecting the runner to see uncommitted files.
Try it yourself
Goal: add a non-blocking check to your practice project.
- Read your project's
.gitlab-ci.ymland list the jobs, their stages and their images. - Run one job's
scriptlines in your own terminal and confirm they pass. - On a branch, add a job that checks something about documentation: a linter, a spell checker, or a link checker.
- Give it
allow_failure: true, push, and read the result on the merge request. - Open the Pipeline editor and use Validate to see what it reports.
Expected result: a new job visible in the pipeline, reporting without blocking, added by you without touching any application code.
Show solution
Step 4's allow_failure: true is the part that makes this a welcome contribution rather than an unwelcome one. Introducing a check that immediately blocks everyone's merges is how good ideas get reverted; introducing it as advisory gets it accepted and then promoted.