Dissecting the anatomy of the .gitlab-ci.yml file from global keywords to stages definitions, then writing your first job with inline and multi-line scripts, handling errors with allow_failure, and assembling a complete hello-world pipeline with build and test stages.

In episode 1 you understood the GitLab CI/CD architecture: the GitLab Server triggers, the Runner executes, and .gitlab-ci.yml describes everything. Now it's time to write your first pipeline code. This episode covers the anatomy of the .gitlab-ci.yml file from scratch — from global keywords and stages definitions, to your first job running build and test, complete with error handling.
Mastering this basic syntax is an absolute foundation: every upcoming episode — rules, variables, Docker, deployments — is built on top of the structure you'll learn now.
.gitlab-ci.yml File.gitlab-ci.yml is a YAML file placed at the repository root. The filename is sacred — GitLab automatically detects it every time there's a push, merge request, or tag. If the file exists, GitLab creates a pipeline and displays it in the project's CI/CD tab.
Before writing jobs, there are several global keywords that govern pipeline behavior overall:
default — settings that apply to all jobs, such as image, before_script, or timeout.stages — defines the sequence of pipeline execution phases.variables — global variables (covered in depth in episode 5).workflow — controls when pipelines are created (episode 4).If you don't define stages at all, GitLab uses its built-in default: .pre, build, test, deploy, .post.
| Stage | Purpose |
|---|---|
.pre | Runs earliest, before all stages |
build | Compiles and produces artifacts |
test | Unit tests, lint, integration tests |
deploy | Deployment to environments |
.post | Runs last, for cleanup or notifications |
Stages with a leading dot (.pre and .post) are special stages: you can't mention them in the stages list, but every job inside them still runs in the appropriate order. For a more explicit pipeline, define your own stages:
stages:
- build
- test
- deployThe order in this list is the execution order. Jobs using a stage that isn't registered will be rejected by GitLab when creating the pipeline.
A job is a YAML block that starts with a job name and contains at least stage and script. Job names are free-form (letters, numbers, underscores), as long as they're unique within the file.
unit_test:
stage: test
script:
- npm testunit_test) — the job identifier in the pipeline UI.stage — determines which phase the job runs in.script — the commands the runner executes.If a job runs only a single command, write it directly as the script value:
unit_test:
stage: test
script: npm testFor several sequential commands, use a YAML list under script:
unit_test:
stage: test
script:
- echo "Menjalankan unit test"
- npm ci
- npm run lint
- npm testEach line is executed as a separate command within one shared shell session — variables set on the first line remain usable on later lines. If any command returns a non-zero exit code, the job fails immediately and subsequent commands are not executed.
Sometimes you have jobs that may fail without stopping the pipeline — for example non-blocking linting or a test that's currently flaky. Use allow_failure: true:
lint:
stage: test
script:
- npm run lint
allow_failure: trueIf the lint job fails, the pipeline still continues to the next stage. In the UI, a job that fails with allow_failure: true still appears orange (warning) — so the problem stays visible, but it doesn't block releases.
Warning
allow_failure: true is an exception, not a habit. Enabling it on almost every job keeps your pipeline permanently "green" even when the code is broken — and that destroys the core value of CI/CD: honest feedback. Use it only for jobs that are truly non-critical or currently stabilizing.
Now let's assemble everything into a real pipeline for a Node.js project:
stages:
- build
- test
before_script:
- npm ci
build_app:
stage: build
script:
- npm run build
unit_test:
stage: test
script:
- npm test
e2e_test:
stage: test
script:
- npx playwright testThere are several important things in the example above:
before_script runs before script in every job — here it's used to install dependencies once for all jobs.unit_test and e2e_test jobs are in the same test stage, so they both run in parallel after the build stage finishes.build_app finishes first → then unit_test and e2e_test start.After this file is pushed, you can monitor the pipeline directly from the terminal with glab ci status:
$ glab ci status
build_app build running
unit_test test pending
e2e_test test pending.gitlab-ci.yml must be at the root; if it's placed in a subdirectory, GitLab won't detect it and no pipeline is ever created.stage: deploy when stages doesn't include it will be rejected by GitLab with the error chosen stage does not exist.script, stage, and allow_failure are at the same indentation level (aligned).In this episode 2, you've written a pipeline that actually runs:
.gitlab-ci.yml: the global keywords default, stages, variables, and workflow..pre, build, test, deploy, .post, and how to define your own stage order.stage, and script, plus the difference between inline commands and multi-line commands.allow_failure: true as a way to handle non-critical jobs without stopping the pipeline.Your pipeline is now alive. In episode 3 we'll dissect who actually runs the pipeline — the GitLab Runner. We'll cover the runner types (shared, group, specific), executor types (shell, docker, kubernetes), how to register a runner with a token, and how to route jobs to a specific runner using tags. See you in episode 3!