8. Artifacts, Caching and Releases
Every job starts in a clean environment, so anything a pipeline produces must be moved on purpose: to a later job, to the next pipeline, or to the people and systems that use the software. This chapter covers the four ways GitLab moves data: job artifacts, which hand a job's outputs to later jobs and to users; caches, which reuse dependencies across pipelines to save time; the package registry, which publishes versioned libraries and binaries; and releases, which tie a tag, its notes and its downloadable assets into one permanent record. Choosing the right mechanism for each piece of data keeps pipelines fast, correct and auditable.
<a id="81-job-artifacts"></a>
8.1 Job Artifacts
Artifacts are files a job uploads to GitLab when it finishes. GitLab hands them to later jobs in the same pipeline and lets users browse and download them. This subchapter covers how to select the files, how long they are kept, when they are uploaded, which jobs receive them, and the special "report" artifacts that GitLab reads itself.
artifacts:paths and exclude
Why it matters. Jobs are isolated (section 2.2). When a job ends, its container and everything the script wrote into it are destroyed. A build job that compiles a binary has achieved nothing unless the binary leaves that container. artifacts:paths is how you name the files that must survive. It is the only guaranteed way to pass files to later jobs in the pipeline.
The artifact lifecycle. Think of artifacts as a parcel service between workstations: the job packs named files into a box, GitLab stores the box, and every later job that is entitled to it gets it unpacked in its own working directory before its script starts.
job "build" (runner A) GitLab job "test" (runner B)
┌──────────────────────────┐ ┌─────────────────────┐ ┌─────────────────────────┐
│ script writes dist/ │ │ artifact storage │ │ fresh checkout │
│ after_script │──zip──────>│ (disk or object │──unzip─>│ dist/ restored into │
│ collect artifacts:paths │ upload │ storage) │ │ $CI_PROJECT_DIR │
└──────────────────────────┘ └─────────────────────┘ │ script runs │
└─────────────────────────┘
Path rules. Paths are relative to the project directory ($CI_PROJECT_DIR). They are collected after after_script, so files written there are included.
| Entry | What it collects |
|---|---|
dist/ | The directory and everything under it, recursively |
dist/app | One file |
dist/*.whl | Files matching a glob in one directory |
reports/**/*.xml | Files at any depth below reports/ (** matches any number of directories) |
out/$CI_JOB_NAME/ | CI/CD variables are expanded |
/tmp/build or ../build | Rejected: artifacts cannot come from outside the project directory |
artifacts:exclude removes matches from the archive using the same pattern syntax. It only filters what paths (or untracked) already selected. Two other keywords complete the selection:
| Keyword | Effect |
|---|---|
artifacts:untracked: true | Adds every file Git does not track, in addition to paths (useful for "collect everything the build created") |
artifacts:name | Name of the downloaded archive. The default is artifacts, giving artifacts.zip |
build-web:
stage: build
image: node:22-slim
script:
- npm ci
- npm run build # writes dist/, including dist/**/*.map
artifacts:
name: "web-$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA" # downloads as web-main-1a2b3c4d.zip
paths:
- dist/
exclude:
- dist/**/*.map # source maps stay out of the archive
- dist/**/.DS_Store
test-e2e:
stage: test
image: mcr.microsoft.com/playwright:v1.48.2-jammy
script:
- ls dist/index.html # dist/ was unpacked here before the script started
- npx playwright test
Reading the job log. The upload step reports what it found. Learn to read it, because a wrong path is not an error:
Uploading artifacts for successful job
Uploading artifacts...
dist/: found 214 matching artifact files and directories
WARNING: coverage/: no matching files. Ensure that the artifact path is relative to the working directory (/builds/acme/web)
Uploading artifacts as "archive" to coordinator... 201 Created id=8812 responseStatus=201 Created
Job succeeded
The job above succeeded even though coverage/ was missing. If a later job depends on that directory, it fails with a confusing "file not found" error. Fail fast in the producing job instead:
script:
- npm run build
- test -f dist/index.html || { echo "build produced no dist/index.html"; exit 1; }
Size limits. Each artifact archive has a maximum size: 100 MB by default on self-managed instances (configurable by administrators per instance, group or project) and 1 GB on GitLab.com. Large archives also slow down every job that downloads them, so exclude intermediate build files (*.o, node_modules/, test fixtures) rather than raising the limit.
Key takeaways
- Artifacts are the guaranteed way to pass files to later jobs. Without them, everything a job writes is lost.
- Paths are relative to
$CI_PROJECT_DIR, support globs and**, and cannot point outside the project directory. excludefilters files thatpathsoruntrackedalready selected.namesets the archive's file name.- A path that matches nothing only produces a warning. Check critical outputs in the script so the producing job fails.
- Keep archives small. They are uploaded once but downloaded by every dependent job.
Practice
- (Beginner) Write a job that creates
out/report.txtandout/debug.log, and keeps onlyreport.txtas an artifact usingpathsandexclude. Download the archive from the job page and check its contents.- (Intermediate) Set
artifacts:pathsto$CI_PROJECT_DIR/../out/and then tobuild/when nobuild/exists. Record the exact log message for each case, and add a script check that makes the second case fail the job.- Interview question: "A test job fails with
dist/main.js: No such file or directory, but the build job before it is green. Walk through how you would debug this." Hint: look at the build job's "Uploading artifacts" section, the working directory, and which jobs the test job actually downloads from.
artifacts:expire_in
Why it matters. Every artifact takes storage, and storage costs money or counts against a namespace quota. Most artifacts are useful for days, not years: an MR's test report matters until the MR merges. Without expiration, a busy project builds up terabytes of archives nobody will open again. Expiring too early is also a problem: a job that needs an expired artifact fails.
Syntax. expire_in takes a duration measured from when the artifact is uploaded. A bare number is a number of seconds. Units can be combined:
| Value | Meaning |
|---|---|
'3600' or 3600 | 3600 seconds |
30 mins | 30 minutes |
1 day, 3 days | Days |
2 weeks and 3 days | Combined units |
6 mos | Six months |
never | Never expires automatically |
If expire_in is not set, the instance default applies: 30 days unless an administrator changed it (Admin > Settings > CI/CD > Continuous Integration and Deployment > Default artifacts expiration).
default:
artifacts:
expire_in: 1 week # default for every job that defines artifacts
lint:
script: ./lint.sh > lint.txt
artifacts:
paths: [lint.txt]
expire_in: 2 days # only interesting while the MR is open
build-release:
script: make release
artifacts:
paths: [dist/]
expire_in: 3 mos # long enough for audits; publish real releases as packages (8.3)
rules:
- if: $CI_COMMIT_TAG
Exceptions to expiration.
| Mechanism | Where | Effect |
|---|---|---|
| Keep artifacts from most recent successful jobs | Settings > CI/CD > Artifacts, enabled by default | The artifacts of the latest successful pipeline on each ref are kept even after expire_in passes. They become eligible for deletion when a newer pipeline on that ref succeeds |
| Keep button | Job page, artifacts sidebar | Keeps one job's artifacts forever, overriding expire_in |
expire_in: never | .gitlab-ci.yml | No automatic expiration |
The first setting explains a common confusion: "I set expire_in: 1 hour but the artifact is still there a week later." It is the latest artifact for main, so it is locked:
main: pipeline 501 (success) ──> pipeline 502 (failed) ──> pipeline 503 (success)
artifacts locked not "successful" artifacts locked
until 503 succeeds expire normally (501's artifacts now expire)
Expired artifacts are removed by a background worker, so they can remain visible for a short time after their expiry date. Expiration deletes the archive files only; the job and its log stay.
Choosing durations. Match the lifetime to how long anyone will need the file:
| Artifact | Suggested lifetime |
|---|---|
| Intermediate build outputs used only within the pipeline | Hours to 1 day |
| Test reports and logs for MRs | 1 to 2 weeks |
| Default-branch builds used by other projects or deployments | A few weeks; the latest is kept anyway |
| Release binaries | Publish to the package registry or release assets instead of never |
Key takeaways
expire_indeletes an artifact a fixed time after upload. The default is 30 days unless the instance changes it.- The latest successful pipeline's artifacts on each ref are locked by default, regardless of
expire_in. - The Keep button and
neveroverride expiration. Use them rarely, because storage grows forever. - A job that needs an expired artifact fails. Size
expire_into the longest gap between producer and consumer. - Use the package registry, not
never, for files that must last as long as the release.
Practice
- (Beginner) Create a job with
expire_in: 5 mins. Check the job page right after the pipeline ends and again an hour later. Explain why the artifact is still there if the pipeline ran onmain.- (Intermediate) Open Usage quotas for your namespace and find the project with the most artifact storage. Propose
expire_invalues per job type for that project and estimate the storage saved.- Interview question: "Finance asks why CI storage tripled this year. What would you look for, and which settings would you change?" Hint:
never, the Keep button, the default expiration, large archives and which artifacts should be packages instead.
artifacts:when
Why it matters. By default, artifacts are uploaded only when the job succeeds. That is backwards for debugging: the screenshots, logs and test reports you need most come from failed jobs. artifacts:when decides which job outcomes upload artifacts.
Values.
| Value | Uploads when | Typical use |
|---|---|---|
on_success (default) | The job succeeds | Build outputs that later jobs consume |
on_failure | The job fails | Screenshots, core dumps, verbose logs that only matter on failure |
always | The job succeeds or fails | Test reports (a failing test run is exactly when you need the report) |
A job with allow_failure: true that fails still counts as failed here: its on_success artifacts are not uploaded.
e2e:
stage: test
image: mcr.microsoft.com/playwright:v1.48.2-jammy
script:
- npx playwright test --reporter=junit,html
after_script:
- docker logs app > logs/app.log 2>&1 || true # after_script runs before artifacts are collected
artifacts:
when: always # upload results whether tests pass or fail
paths:
- playwright-report/ # HTML report, browsable from the job page
- test-results/ # screenshots and traces of failed tests
- logs/
reports:
junit: results.xml # test results in the MR widget (10.1)
expire_in: 1 week
when applies to the whole artifacts block. If one job needs different rules for different files, for example build output only on success but logs always, split the work into two jobs or accept that the build output is also uploaded on failure.
Timeouts and cancellation. Two outcomes are neither a normal success nor a normal failure:
| Outcome | What happens to artifacts |
|---|---|
Job is canceled (by a user, interruptible, or auto-cancel) | Not uploaded |
| Job hits its timeout | The runner stops the job. Without spare time, artifact upload may never happen |
To keep artifacts from jobs that time out, give the script its own, shorter timeout with the runner variable RUNNER_SCRIPT_TIMEOUT. When it expires, the runner stops the script but still runs after_script and the artifact upload within the job timeout:
long-integration:
timeout: 60m # hard job limit
variables:
RUNNER_SCRIPT_TIMEOUT: 50m # stop the script first, leaving 10 minutes to upload
script:
- ./run-integration-suite.sh
artifacts:
when: on_failure
paths: [logs/]
Key takeaways
on_successis the default. Diagnostic files needon_failureoralways.- Use
when: alwaysfor test reports, so failed runs still report their results. - Files written in
after_scriptare included, which makes it the place to collect logs. - Canceled jobs do not upload artifacts. For jobs at risk of timing out, set
RUNNER_SCRIPT_TIMEOUTbelow the job timeout.
Practice
- (Beginner) Write a job that writes
debug.logand then runsexit 1. Show that the log is missing with the defaultwhenand present withwhen: on_failure.- (Intermediate) Create a job with
timeout: 2mwhose script runssleep 300after writingprogress.log. Make the log appear as an artifact usingRUNNER_SCRIPT_TIMEOUT.- Interview question: "The MR widget shows 'no test results' whenever the test job fails, but shows them when it passes. What is wrong?" Hint: the default value of one artifact keyword.
dependencies Keyword
Why it matters. By default, a job downloads the artifacts of every job in every earlier stage. In a pipeline with large build outputs, a deploy job may spend minutes downloading binaries for platforms it does not deploy. Worse, an unrelated file with the same name can overwrite the one you expect. dependencies limits which jobs a job downloads from.
Default behavior versus dependencies.
stage build: build-linux (300 MB) build-macos (280 MB) build-docs (20 MB)
│ │ │
stage test: test-linux ── default: downloads all three (600 MB)
test-linux ── dependencies: [build-linux] ── downloads 300 MB
stage deploy: notify ── dependencies: [] ── downloads nothing
stages: [build, test, deploy]
build-linux:
stage: build
script: make TARGET=linux
artifacts:
paths: [dist/linux/]
build-docs:
stage: build
script: make docs
artifacts:
paths: [public/]
test-linux:
stage: test
dependencies: [build-linux] # only dist/linux/, not public/
script: ./dist/linux/app --self-test
pages:
stage: deploy
dependencies: [build-docs]
script: echo "publishing docs"
artifacts:
paths: [public/]
notify:
stage: deploy
dependencies: [] # download nothing; this job only calls a webhook
script: curl -fsS -X POST "$CHAT_WEBHOOK_URL" -d "Pipeline $CI_PIPELINE_ID finished"
Rules and errors.
| Situation | Result |
|---|---|
| Listing a job from an earlier stage | Its artifacts are downloaded |
| Listing a job from the same or a later stage | Configuration error |
| A listed job's artifacts have expired or were erased | The job fails with a missing-dependency error ("This job depends on other jobs with expired/erased artifacts") |
A listed job was skipped by its rules | Nothing to download from it, no error |
dotenv reports (section 5.3) follow the same selection: a job only receives the variables of jobs it downloads artifacts from.
dependencies and needs. In DAG pipelines (section 3.2), needs already decides both the order and the artifact downloads, and needs:artifacts: false turns the download off. dependencies only selects downloads and does not change when the job starts. Use one or the other per job:
| Goal | Stage-based pipeline | DAG pipeline |
|---|---|---|
| Download from selected jobs only | dependencies: [a, b] | needs: [a, b] |
| Wait for a job but download nothing from it | Not possible with dependencies alone | needs: [{ job: a, artifacts: false }] |
| Download nothing at all | dependencies: [] | needs: [] with no listed jobs, or artifacts: false on each |
dependencies cannot be set under default, so it must be written on each job that needs it (a hidden job with extends, section 9.2, keeps that tidy).
Key takeaways
- Without
dependenciesorneeds, a job downloads artifacts from all jobs in earlier stages. dependencies: [job]selects sources.dependencies: []downloads nothing.- Only earlier-stage jobs can be listed. Expired artifacts of a listed job fail the job.
- In DAG pipelines, use
needsandneeds:artifactsinstead ofdependencies.
Practice
- (Beginner) Build a three-stage pipeline where two build jobs create artifacts with the same file name,
dist/app. Show which file a test job ends up with by default, then fix it withdependencies.- (Intermediate) Measure the "Downloading artifacts" time of every job in an existing pipeline. Add
dependenciesorneedsto remove all unnecessary downloads and compare total pipeline duration.- Interview question: "A deploy job that ran fine last month now fails immediately when retried, without running its script. What happened?" Hint: retrying an old job reuses the old pipeline's artifacts.
Report Artifacts
Why it matters. Regular artifacts are opaque files for people and jobs. Report artifacts are files in formats GitLab itself understands. GitLab parses them and turns them into features: test results in the merge request, coverage lines in the diff, security findings in the vulnerability report, variables for later jobs. One line of YAML connects a tool you already run to the GitLab UI.
How they work. A job declares a report type and a file. When the job finishes, the runner uploads the file as a separate artifact with that type, and GitLab processes it.
pytest --junitxml=report.xml ──> artifacts:reports:junit: report.xml ──> GitLab parses XML
├─> MR widget: "3 failed, 412 passed"
├─> pipeline "Tests" tab
└─> failure history per test
Common report types.
| Report | File format | GitLab feature | Covered in |
|---|---|---|---|
junit | JUnit XML | Test summary in MRs and pipelines | 10.1 |
coverage_report | Cobertura or JaCoCo XML (coverage_format) | Coverage annotations in MR diffs | 10.2 |
codequality | Code Climate JSON | Code quality widget and diff annotations | 10.3 |
dotenv | KEY=value lines | Variables for later jobs | 5.3 |
sast, secret_detection, dependency_scanning, container_scanning, dast | GitLab security report JSON | Security widget and vulnerability report | 11.1, 11.2 |
cyclonedx | CycloneDX JSON | Dependency list (SBOM) | 11.1 |
terraform | Terraform plan JSON summary | Terraform plan widget in MRs | 12.5 |
metrics | OpenMetrics text | Metrics comparison in MRs | - |
annotations | GitLab annotations JSON | External links on the job page | - |
test:
stage: test
image: python:3.12-slim
script:
- pip install -r requirements.txt pytest pytest-cov
- pytest --junitxml=report.xml --cov=app --cov-report=xml:coverage.xml --cov-report=term
coverage: '/TOTAL.*\s+(\d+%)$/' # coverage percentage parsed from the log (10.2)
artifacts:
when: always # report failures too
paths:
- report.xml # also browsable/downloadable like a normal artifact
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
expire_in: 2 weeks
Behaviors to know.
- Reports are not browsable by default. A file listed only under
reportsis processed by GitLab but does not appear in the job's artifact browser. Add it topathsas well if people need to download it. - Reports from several jobs are combined. Ten
paralleltest jobs (section 4.5) each uploadingjunitproduce one combined test summary for the pipeline. - Reports expire too.
expire_inapplies to them. An MR whose pipeline's reports have expired loses its widget data. - Globs work for some types.
junit: reports/**/*.xmlcollects many files into one report.
Key takeaways
- Report artifacts are files GitLab parses to power MR widgets, test tabs, security dashboards and job-to-job variables.
- Declare them under
artifacts:reports:<type>. Add the same file topathsif people should be able to download it. - Use
when: alwayson test and scan jobs so failures are reported. - Reports from parallel jobs are merged. Expired reports disappear from the UI.
Practice
- (Beginner) Add a
junitreport to an existing test job, make one test fail on purpose in a merge request, and find the failure in the MR widget and in the pipeline's Tests tab.- (Intermediate) Split the test suite across
parallel: 3jobs, each producing its own JUnit file. Confirm that the pipeline shows one combined test count.- Interview question: "Why does GitLab parse report artifacts instead of letting each tool post results to the MR through the API?" Hint: think about tokens, tool independence, and data tied to a specific pipeline and commit.
Browsing and Downloading Artifacts
Why it matters. Artifacts are also for people and other systems: a QA engineer downloading a build, a reviewer opening an HTML coverage report, a deployment script fetching the latest main build. Knowing every access path, and how to control it, turns artifacts into a lightweight distribution channel.
In the UI.
| Location | What you can do |
|---|---|
| Job page, right sidebar | Download, Browse (open single files), Keep |
| Pipeline list and pipeline page | Download menu with every job's archive |
| Build > Artifacts | All artifacts of the project with sizes; delete one or many |
| Merge request widget | Links to artifacts exposed with artifacts:expose_as |
When GitLab Pages is enabled on the instance, HTML files in artifacts open as rendered pages from the artifact browser, which is handy for coverage or test reports.
Exposing artifacts in merge requests. expose_as adds a link to the MR widget, so reviewers do not have to dig through jobs:
coverage-html:
script: pytest --cov=app --cov-report=html:htmlcov
artifacts:
expose_as: "Coverage report" # link label in the MR widget
paths:
- htmlcov/index.html # a single file opens directly; a directory opens the browser
expose_as paths cannot use glob patterns, and an MR shows at most 10 exposed artifacts.
Stable URLs. Two URL families exist. Job-based URLs point at one specific job. Ref-based URLs point at the latest successful pipeline on a branch or tag, so they always serve the newest good build:
# One specific job (ID 8812)
https://gitlab.example.com/acme/web/-/jobs/8812/artifacts/download # whole archive
https://gitlab.example.com/acme/web/-/jobs/8812/artifacts/browse
https://gitlab.example.com/acme/web/-/jobs/8812/artifacts/raw/dist/index.html
# Latest successful pipeline on main, job named "build-web"
https://gitlab.example.com/acme/web/-/jobs/artifacts/main/download?job=build-web
https://gitlab.example.com/acme/web/-/jobs/artifacts/main/raw/dist/version.txt?job=build-web
If the newest pipeline on main failed, the ref-based URL serves the previous successful one. Users are often surprised by that.
From scripts: the API and glab.
TOKEN="glpat-..." # personal or project access token with read_api
API="https://gitlab.example.com/api/v4/projects/12345"
# Download the archive of job "build-web" from the latest successful pipeline on main
curl --fail --location --output web.zip \
--header "PRIVATE-TOKEN: $TOKEN" \
"$API/jobs/artifacts/main/download?job=build-web"
# Read one file without downloading the whole archive
curl --fail --header "PRIVATE-TOKEN: $TOKEN" \
"$API/jobs/artifacts/main/raw/dist/version.txt?job=build-web"
# Keep one job's artifacts forever, or delete them
curl --fail --request POST --header "PRIVATE-TOKEN: $TOKEN" "$API/jobs/8812/artifacts/keep"
curl --fail --request DELETE --header "PRIVATE-TOKEN: $TOKEN" "$API/jobs/8812/artifacts"
# GitLab CLI: download and unpack the artifacts of job "build-web" on main
glab job artifact main build-web --path ./web
Inside a job, use the JOB-TOKEN: $CI_JOB_TOKEN header instead of a personal token. Cross-project downloads also need the source project's job token allowlist (sections 3.4 and 11.4).
Controlling access. By default, anyone who can see the pipeline can download its artifacts, which includes the public in a public project. artifacts:access restricts that:
| Value | Who can download from the UI and API |
|---|---|
all (default) | Everyone with access to the pipeline |
developer | Users with at least the Developer role |
none | Nobody. Later jobs in the pipeline still receive the artifacts |
build-internal:
script: make build-with-debug-symbols
artifacts:
access: developer # public project, but internal debug builds
paths: [dist/]
Key takeaways
- Artifacts can be downloaded from the job page, the pipeline, the Build > Artifacts page and MR links created with
expose_as. - Ref-based URLs serve the latest successful pipeline on a ref. Job-based URLs serve one exact job.
- The API and
glab job artifactprovide the same access to scripts. UseCI_JOB_TOKENinside jobs. - Use
artifacts:accessto keep artifacts of public projects away from anonymous users.
Practice
- (Beginner) Add
expose_asto a job that produces an HTML report, open an MR, and follow the link from the MR widget.- (Intermediate) Write a shell script that downloads
dist/version.txtfrom the latest successfulmainpipeline with the API, prints it, and exits with an error if the request fails. Then run it once from your laptop with a personal access token and once inside a job withCI_JOB_TOKEN.- Interview question: "A public open-source project accidentally exposed a
.envfile in a build artifact. What do you do right now, and what prevents it from happening again?" Hint: rotate first, then delete artifacts in bulk, thenexclude,artifacts:accessand review ofuntracked.
<a id="82-caching"></a>
8.2 Caching
A cache stores files that jobs can reuse across pipelines, typically downloaded dependencies, so that each run does not fetch them again from the internet. This subchapter explains how caching differs from artifacts, how cache keys and policies decide what is shared and when, how fallback keys reduce cold starts, and how to run and clean a cache shared by many runners.
Cache vs Artifacts
Why it matters. Caches and artifacts are both "files that survive a job", and both are configured with paths. They solve different problems, though, and mixing them up causes the two most common pipeline bugs: jobs that fail at random because they relied on a cache to pass build output, and pipelines that are slow because dependencies were uploaded as artifacts.
The core difference. An artifact is a delivery: one job produces it, and GitLab guarantees that the jobs that should receive it get it. A cache is a shelf in a shared workshop: whoever arrives can take what is there and put things back, but nobody promises the shelf is stocked. The job must work, only slower, when the shelf is empty.
| Cache | Artifacts | |
|---|---|---|
| Purpose | Make jobs faster by reusing dependencies | Pass job results to later jobs and to people |
| Guarantee | Best effort: may be missing, stale or from another pipeline | Guaranteed until expired |
| Shared between | Jobs and pipelines that use the same key (and the same runner, unless the cache is distributed) | Jobs of the same pipeline (plus downloads by people and APIs) |
| Stored | On the runner host, or in object storage configured on the runner | In GitLab (disk or object storage of the instance) |
| Lifetime | Until overwritten, cleared or removed by storage cleanup | expire_in |
| Visible in the UI | No | Yes, browsable and downloadable |
| Restored | Before before_script | Before before_script |
| Saved | After after_script | After after_script |
| Typical content | .npm/, .cache/pip/, .m2/repository/, .gradle/caches/ | dist/, binaries, test reports, dotenv files |
The rule of thumb: if the job fails without the files, they must be artifacts; if it only gets slower, they belong in a cache.
pipeline 1 pipeline 2 (next day)
cache (key deps-a1b2) install ──push──> [ shelf ] ──pull──> install, test, lint
artifacts build ──> test ──> deploy build ──> test ──> deploy
(delivered within the pipeline only)
Correct use of both in one pipeline.
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" # pip's download cache must be inside the project dir
default:
image: python:3.12-slim
cache:
key:
files: [requirements.txt] # new cache when dependencies change (next topic)
paths:
- .cache/pip/ # downloaded wheels: nice to have, not required
build:
stage: build
script:
- pip install -r requirements.txt build
- python -m build # writes dist/
artifacts:
paths: [dist/] # required by later jobs: artifact, not cache
test:
stage: test
script:
- pip install -r requirements.txt dist/*.whl # fast when the cache is warm, still works when cold
- pytest
Cache directories must be inside the project. Like artifacts, cache paths must be under $CI_PROJECT_DIR. Most tools default to a directory in $HOME, so point them into the project:
| Tool | Setting | Cache path |
|---|---|---|
| pip | PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip | .cache/pip/ |
| npm | npm ci --cache .npm --prefer-offline | .npm/ |
| Yarn (v1) | yarn install --cache-folder .yarn-cache | .yarn-cache/ |
| Maven | MAVEN_OPTS: -Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository | .m2/repository/ |
| Gradle | GRADLE_USER_HOME: $CI_PROJECT_DIR/.gradle | .gradle/caches/, .gradle/wrapper/ |
| Go | GOMODCACHE: $CI_PROJECT_DIR/.go/pkg/mod | .go/pkg/mod/ |
Prefer caching the package manager's download cache over the installed result (such as node_modules/). npm ci deletes node_modules/ before installing anyway, and download caches are safe to share between jobs with different install options.
Key takeaways
- Artifacts are guaranteed deliveries inside a pipeline. Caches are best-effort reuse across pipelines.
- Never pass build output through a cache. Jobs must work, only slower, when the cache is missing.
- Cache paths must be inside
$CI_PROJECT_DIR. Redirect tool caches there with environment variables or flags. - Cache download caches (
.npm, pip,.m2), not installed trees, whenever possible.
Practice
- (Beginner) Run an npm project's test job three times: without a cache, with
.npm/cached (second run), and after clearing the runner cache. Record the install time in each case.- (Intermediate) Find a pipeline that passes
dist/ortarget/throughcache. Convert it to artifacts and explain the failure scenario the original version allowed.- Interview question: "Explain the difference between cache and artifacts to a developer who has just seen a test job fail only on some runners." Hint: local caches live on one runner host, and guarantees differ.
cache:key and cache:key:files
Why it matters. The key decides who shares a cache. If the key is too broad, unrelated branches overwrite each other's dependencies and jobs restore incompatible files. If it is too narrow, every pipeline starts cold and the cache does nothing. Choosing the key is the main design decision in caching.
Default and simple keys. Without cache:key, every job in the project uses the key default. A key can be any string with CI/CD variables:
| Key | Who shares the cache | Good for |
|---|---|---|
(none) → default | Every job and branch | Tiny projects with one dependency set |
$CI_COMMIT_REF_SLUG | Jobs on the same branch or tag | Branches whose dependencies drift apart |
$CI_JOB_NAME | The same job on every branch | Jobs with very different caches (lint vs. build) |
$CI_JOB_NAME-$CI_COMMIT_REF_SLUG | The same job on the same branch | Strict isolation, most cold starts |
cache:key:files | Every job and branch whose lockfile is identical | Dependency caches (recommended) |
A key cannot contain / (or its URL-encoded form %2F), and cannot be only . or ...
Keys derived from files. Dependency caches should change exactly when the dependencies change. cache:key:files lists up to two files, typically lockfiles. GitLab computes a SHA from them, so the key stays the same across branches and pipelines until one of the files changes. cache:key:prefix adds a readable prefix to the computed key:
test:
image: node:22-slim
cache:
key:
files:
- package-lock.json # key changes only when the lockfile changes
prefix: npm # final key looks like npm-5f3c9a...
paths:
- .npm/
script:
- npm ci --cache .npm --prefer-offline
- npm test
If none of the listed files exists in the commit, the key falls back to default (with the prefix, if one is set), which silently shares one cache across all branches. Check that the file names are right.
Several caches in one job. cache can be a list of up to four caches, each with its own key and policy. Use this when parts of the cache change at different rates:
build:
image: gradle:8.10-jdk21
variables:
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
cache:
- key:
files: [gradle/wrapper/gradle-wrapper.properties] # changes when the Gradle version changes
paths: [.gradle/wrapper/]
- key:
files: [build.gradle.kts, gradle.lockfile] # up to two files per key
paths: [.gradle/caches/]
script:
- ./gradlew build --no-daemon
Protected and unprotected caches. By default (Settings > CI/CD > General pipelines > Use separate caches for protected branches), GitLab adds a suffix to every key: -protected for jobs on protected branches and tags, -non_protected for everything else. A feature branch can then never write files that a later main or release job restores. This matters for security: an MR author could otherwise put a malicious package into the cache that the production pipeline uses. The job log shows the full key:
Restoring cache
Checking cache for npm-5f3c9a1d0e-protected...
Downloading cache from https://storage.googleapis.com/acme-runner-cache/project/12345/npm-5f3c9a1d0e-protected
Successfully extracted cache
cache:unprotect: true removes the separation for one cache. Use it only for content that cannot affect the build's output, and never for caches that protected deploy jobs use.
Key takeaways
- The key decides who shares a cache. The default key
defaultis shared by everything. cache:key:files(up to two files, plus an optionalprefix) gives a key that changes only when the dependencies change.- A job can use up to four caches with different keys.
- Protected and unprotected refs use separate caches by default. That separation is a security control.
Practice
- (Beginner) Change a job's cache key from the default to
$CI_COMMIT_REF_SLUG, run it on two branches, and find both keys in the job logs.- (Intermediate) Switch a Node.js project to
cache:key:files: [package-lock.json]. Show that two branches with the same lockfile share one cache, and that updating a dependency creates a new key.- Interview question: "Why does GitLab separate protected and unprotected caches by default, and what could happen if you disabled it?" Hint: consider who can push to a feature branch and which jobs hold deployment credentials.
Cache Policies: pull, push, pull-push
Why it matters. By default, every job that uses a cache downloads it at the start and uploads it again at the end. With ten test jobs sharing one 400 MB cache, that is ten uploads of an archive that did not change, wasting minutes and sometimes overwriting a good cache with a worse one. cache:policy controls the direction of transfer.
The three policies.
| Policy | Downloads at start | Uploads at end | Use for |
|---|---|---|---|
pull-push (default) | Yes | Yes | The one job that keeps the cache up to date |
pull | Yes | No | Every job that only consumes the cache |
push | No | Yes | A job that always rebuilds the cache from scratch |
┌── test-unit (pull) ──┐
install ─────┼── test-api (pull) ──┼──> nobody else uploads; one fresh cache per lockfile
(pull-push) └── lint (pull) ──┘
.npm-cache: &npm-cache # YAML anchor shared by all jobs (section 2.1)
key:
files: [package-lock.json]
prefix: npm
paths: [.npm/]
policy: pull # consumers only
stages: [prepare, test]
default:
image: node:22-slim
warm-cache:
stage: prepare
cache:
<<: *npm-cache
policy: pull-push # the single writer
script:
- npm ci --cache .npm --prefer-offline
test-unit:
stage: test
cache: *npm-cache
script:
- npm ci --cache .npm --prefer-offline # fast: packages come from the restored .npm/
- npm run test:unit
lint:
stage: test
cache: *npm-cache
script:
- npm ci --cache .npm --prefer-offline
- npm run lint
Choosing the policy dynamically. cache:policy accepts a CI/CD variable, so rules can decide per pipeline. A common pattern lets only the default branch write the cache, while MR pipelines only read it:
test:
variables:
CACHE_POLICY: pull # MRs and feature branches: read only
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
CACHE_POLICY: pull-push # main keeps the cache fresh
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
cache:
key:
files: [package-lock.json]
paths: [.npm/]
policy: $CACHE_POLICY
script:
- npm ci --cache .npm --prefer-offline
- npm test
When the cache is saved. cache:when works like artifacts:when: on_success (default), on_failure or always. Keep the default for dependency caches, so a job that failed halfway through an install does not upload a broken cache. Parallel jobs that push the same key overwrite each other, and the last upload wins.
Key takeaways
pull-pushis the default and uploads after every job. Most consumer jobs should usepull.- Have one writer per key, either a dedicated job or the default branch through a variable policy.
pushrebuilds the cache without downloading the old one.- Caches are saved only on success by default, which protects against half-installed caches.
Practice
- (Beginner) In a pipeline with three jobs sharing a cache, set two of them to
pull. Compare the "Saving cache" sections of the job logs before and after.- (Intermediate) Implement the variable-policy pattern so that only
mainupdates the cache. Prove it by checking the logs of an MR pipeline and amainpipeline.- Interview question: "Twenty parallel test jobs share one cache and the pipeline got slower after caching was added. Why, and how do you fix it?" Hint: count the uploads, and think about which job should own the cache.
Fallback Cache Keys
Why it matters. A precise key such as a lockfile hash or a branch name has a cost: the first pipeline after the key changes starts with an empty cache and downloads everything. Usually an older cache would have contained 95% of what is needed. Fallback keys let a job start from the closest available cache instead of from nothing.
Lookup order. When a job restores a cache, the runner tries keys in order and stops at the first one that exists:
1. cache:key npm-<new lockfile hash> miss (lockfile just changed)
2. cache:fallback_keys[0] npm-main hit -> restore it
3. cache:fallback_keys[1] npm-default
4. CACHE_FALLBACK_KEY variable global last resort
At the end of the job, the cache is always saved under key 1 (if the policy pushes).
test:
image: node:22-slim
cache:
key: npm-$CI_COMMIT_REF_SLUG # each branch has its own cache...
fallback_keys: # ...but a new branch starts from main's
- npm-$CI_DEFAULT_BRANCH
- npm-default
paths: [.npm/]
script:
- npm ci --cache .npm --prefer-offline
- npm test
fallback_keys takes up to five keys. CACHE_FALLBACK_KEY is a CI/CD variable (set in variables or project settings) that acts as one more fallback for every cache in the project.
The protected-cache trap. Fallback keys get the same -protected or -non_protected suffix as the main key. A feature-branch job therefore looks for npm-main-non_protected, which does not exist if main is protected: its cache is stored as npm-main-protected. The fallback silently misses. The separation is intentional (section above), so the usual fixes are:
| Option | Effect | Trade-off |
|---|---|---|
| Accept the miss | Feature branches warm their own cache on the first run | One cold run per branch |
| Let an unprotected job maintain a shared fallback key, for example a scheduled pipeline on an unprotected branch | Feature branches find a -non_protected cache | An extra job to maintain |
cache:unprotect: true | One cache shared by all refs | Feature branches can write files that protected jobs restore. Avoid for anything that affects build output |
Key takeaways
- Fallback keys reduce cold starts by restoring the closest existing cache when the primary key misses.
- The order is:
key, thenfallback_keys(up to five), thenCACHE_FALLBACK_KEY. Uploads always go to the primary key. - Fallbacks keep the protected/unprotected suffix, so unprotected branches cannot fall back to a protected branch's cache.
Practice
- (Beginner) Add
fallback_keys: [npm-$CI_DEFAULT_BRANCH]to a branch-keyed cache. Create a new branch and check in the job log which key was restored.- (Intermediate) Reproduce the protected-cache trap: protect
main, run its pipeline, then run a feature branch and show that the fallback misses. Design a fix that does not useunprotect.- Interview question: "Your team switched cache keys to lockfile hashes and now every dependency update causes a 6-minute install. How would you keep precise keys without the cold starts?" Hint: fallbacks, and what an older cache still contains.
Distributed Cache with Object Storage
Why it matters. By default the cache lives on the runner host that ran the job. With several runner hosts, or with autoscaled runners whose machines are deleted after a few jobs (section 6.4), the next job rarely lands on the machine that holds the cache. Caching then mostly misses. A distributed cache stores cache archives in object storage that every runner can reach, so a cache saved by one machine can be restored by any other.
Local cache Distributed cache
runner host A: [cache npm-5f3c] runner host A ──┐
runner host B: [empty] <- job lands here runner host B ──┼──> S3 / GCS / Azure bucket: [cache npm-5f3c]
miss, full download runner host C ──┘ hit from any host
How it works. The cache is configured in the runner's config.toml (section 6.3), not in .gitlab-ci.yml. Jobs keep the same cache: syntax. The runner manager holds the storage credentials and gives each job short-lived pre-signed URLs to download and upload one archive, so the job itself never sees the bucket credentials.
# /etc/gitlab-runner/config.toml (excerpt)
[[runners]]
name = "docker-autoscaler-eu"
executor = "docker-autoscaler"
[runners.cache]
Type = "s3" # s3 (also S3-compatible such as MinIO), gcs or azure
Path = "gitlab-ci" # prefix inside the bucket
Shared = true # share between all runners using this bucket
MaxUploadedArchiveSize = 2147483648 # 2 GiB; larger caches are not uploaded
[runners.cache.s3]
ServerAddress = "s3.amazonaws.com"
BucketName = "acme-runner-cache"
BucketLocation = "eu-central-1"
AuthenticationType = "iam" # use the instance role, no keys in the file
Other backends follow the same pattern:
# Google Cloud Storage
[runners.cache]
Type = "gcs"
Shared = true
[runners.cache.gcs]
BucketName = "acme-runner-cache"
CredentialsFile = "/etc/gitlab-runner/gcs-cache-sa.json" # or workload identity on GKE
# Azure Blob Storage
[runners.cache]
Type = "azure"
Shared = true
[runners.cache.azure]
AccountName = "acmecicache"
AccountKey = "..." # prefer a managed identity where supported
ContainerName = "runner-cache"
# Self-hosted MinIO (S3 API)
[runners.cache]
Type = "s3"
Shared = true
[runners.cache.s3]
ServerAddress = "minio.internal:9000"
AccessKey = "ci-cache"
SecretKey = "..."
BucketName = "runner-cache"
Insecure = false # true only for plain-HTTP test setups
Shared decides the object path.
Shared | Object path | Effect |
|---|---|---|
false (default) | <Path>/runner/<runner token prefix>/project/<id>/<key> | Each runner has its own copy of the cache |
true | <Path>/project/<id>/<key> | All runners using the bucket share the cache |
Tuning transfer. Cache archives are compressed and uploaded on every push, so compression trades CPU for network time. With the runner feature flag FF_USE_FASTZIP enabled, these job variables apply:
variables:
FF_USE_FASTZIP: "true"
CACHE_COMPRESSION_LEVEL: fast # fastest, fast, default, slow, slowest
ARTIFACT_COMPRESSION_LEVEL: default # the same setting for artifacts
CACHE_REQUEST_TIMEOUT: "5" # minutes for a single cache download or upload
Cleanup is your job. GitLab never deletes objects in the cache bucket. Add a lifecycle rule so old keys (for example, caches of lockfiles from months ago) are removed:
# Delete cache objects 14 days after they were last written
aws s3api put-bucket-lifecycle-configuration \
--bucket acme-runner-cache \
--lifecycle-configuration '{
"Rules": [{
"ID": "expire-ci-cache",
"Status": "Enabled",
"Filter": { "Prefix": "gitlab-ci/" },
"Expiration": { "Days": 14 }
}]
}'
Place the bucket in the same region as the runners. Cross-region transfer adds latency and egress cost to every job.
Key takeaways
- Local caches only help when a job lands on the same host. Autoscaled and multi-host runners need a distributed cache.
- The distributed cache is configured in
config.tomlwith S3, GCS or Azure. Jobs keep the samecache:syntax. - The runner manager signs short-lived URLs, so jobs never receive the bucket credentials.
Shared = truelets all runners share one copy. Lifecycle rules on the bucket do the cleanup.
Practice
- (Beginner) Run MinIO in a container, configure a test runner to use it as an S3 cache, and find a cache archive in the MinIO console after a job runs.
- (Intermediate) Register two runners against the same bucket, first with
Shared = falseand then withShared = true. Show from job logs and bucket contents when a job on runner B can restore a cache saved by runner A.- Interview question: "After moving to autoscaled runners, your cache hit rate dropped from 90% to 10%. Explain why and design the fix, including cost and security." Hint: where caches live, IAM roles versus static keys, regions, and lifecycle rules.
Clearing Caches
Why it matters. Caches go bad: a corrupted archive, a dependency that was cached in a broken state, a tool upgrade that cannot read old cache files. Symptoms are confusing ("works on my machine and on a fresh runner, fails in CI"). You need a quick way to force fresh caches, and a way to remove the space old caches take.
Logical clearing: invalidate the keys. The Clear runner caches button on Build > Pipelines (Maintainer role) increments a project-wide cache index. Every key gets a new number, so the next jobs miss and start fresh:
before clearing: npm-5f3c9a1d0e-protected
after clearing: npm-5f3c9a1d0e-1-protected (index 1, then 2, 3... on later clears)
This does not delete anything. The old archives remain on runner hosts or in the bucket until something removes them.
Clearing from the configuration. You can also change the key in YAML, for example with a version prefix. This clears the cache for one job or one branch only, and the change is reviewed like any other code:
variables:
CACHE_VERSION: "v2" # bump to invalidate every cache that uses it
test:
cache:
key:
files: [package-lock.json]
prefix: npm-$CACHE_VERSION
paths: [.npm/]
Physical cleanup.
| Where the cache lives | How to delete old data |
|---|---|
| Docker executor, local cache | Cache volumes on the runner host: /usr/share/gitlab-runner/clear-docker-cache (prunes the runner's volumes), or docker volume prune for unused volumes |
| Shell executor, local cache | Files under the runner's cache directory (by default cache/ in the runner's working directory) |
| Distributed cache | Bucket lifecycle rules (previous topic), or delete the project's prefix manually |
Disabling or diagnosing the cache. To check whether the cache is the cause of a failure, turn it off for one job. cache: [] overrides a cache inherited from default:
test-without-cache:
extends: test
cache: [] # no restore, no save: proves whether the cache is involved
The job log tells you what happened with each cache:
| Log message | Meaning |
|---|---|
Checking cache for <key>... then Successfully extracted cache | Hit |
WARNING: file does not exist / Failed to extract cache | Miss for that key (the runner then tries fallbacks) |
Not uploading cache <key> due to policy | The job's policy is pull |
Creating cache <key>... then Created cache | Saved after the job |
No URL provided, cache will not be downloaded from shared cache server. Instead a local version of cache will be extracted. | The runner has no distributed cache configured, so it uses its local cache |
Key takeaways
- Clear runner caches invalidates all keys of the project by incrementing an index. It deletes nothing.
- A version prefix in the key clears selected caches through a reviewed change.
- Remove old data separately: runner volumes for local caches, lifecycle rules for buckets.
cache: []disables caching for one job, which is the quickest way to rule the cache in or out.
Practice
- (Beginner) Click Clear runner caches, rerun a pipeline, and find the new key with its index in the job log.
- (Intermediate) Cache a deliberately broken file (for example, a truncated package archive in
.npm/), observe the failure, and recover using a version prefix in the key. Then explain how to also free the space on the runner host.- Interview question: "A build fails only in CI with an error from inside a cached dependency, and passes locally. What steps do you take, in what order?" Hint: disable the cache for one run, compare keys, then decide between clearing and fixing the key design.
<a id="83-package-registry"></a>
8.3 Package Registry
Artifacts expire and belong to one pipeline. Packages are versioned, permanent and installed with the standard tools of each ecosystem. Every GitLab project has a package registry that speaks the protocols of npm, Maven, PyPI, NuGet, Composer, Conan, Helm, RubyGems and others, plus a "generic" format for any file. This subchapter shows how to publish the most common formats from CI and how the job token authenticates those requests.
All formats share one model: you publish to a project, and you install from the project, from its group (all projects in the group at once) or, for some formats, from the whole instance.
| Format | Publish endpoint (project) | Install from group | Client |
|---|---|---|---|
| npm | /projects/:id/packages/npm/ | /groups/:id/-/packages/npm/ | npm, yarn, pnpm |
| Maven | /projects/:id/packages/maven | /groups/:id/-/packages/maven | mvn, Gradle |
| PyPI | /projects/:id/packages/pypi | /groups/:id/-/packages/pypi/simple | twine, pip, uv |
| Generic | /projects/:id/packages/generic/:name/:version/:file | No (project only) | curl |
All paths are relative to $CI_API_V4_URL, for example https://gitlab.example.com/api/v4.
Publishing npm Packages
Why it matters. Shared JavaScript code (UI components, API clients, configuration presets) has to be installable with npm install, versioned, and private to the company. Publishing to the GitLab npm registry from a tag pipeline gives you exactly that, with no extra registry service and no long-lived tokens.
Naming. Use a scoped name whose scope is your top-level group, for example @acme/ui-kit in group acme. Only names that follow this convention can be installed through the instance-level endpoint, and the scope is what tells npm to use GitLab instead of npmjs.com:
{
"name": "@acme/ui-kit",
"version": "0.0.0-development",
"main": "dist/index.js",
"files": ["dist/"],
"publishConfig": {
"access": "restricted"
}
}
The version in package.json is a placeholder here. CI sets the real version from the tag.
Publishing from a tag pipeline. The job writes an .npmrc that maps the scope to the project's endpoint and authenticates with the job token:
stages: [build, test, publish]
publish-npm:
stage: publish
image: node:22-slim
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ # release tags only (section 3.1)
script:
- npm ci
- npm run build
# Map the @acme scope to this project's npm endpoint
- echo "@acme:registry=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/npm/" > .npmrc
# Credentials line: the URL without "https:" (strip everything up to the first colon)
- echo "${CI_API_V4_URL#*:}/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}" >> .npmrc
# v1.4.0 -> 1.4.0, written into package.json without creating a Git tag
- npm version "${CI_COMMIT_TAG#v}" --no-git-tag-version
- npm publish
The generated .npmrc looks like this (the token is masked in the log):
@acme:registry=https://gitlab.example.com/api/v4/projects/12345/packages/npm/
//gitlab.example.com/api/v4/projects/12345/packages/npm/:_authToken=[MASKED]
Pre-releases and dist-tags. npm install @acme/ui-kit installs the version tagged latest. Publish release candidates under another dist-tag so that nobody gets them by accident:
npm version "1.5.0-rc.${CI_PIPELINE_IID}" --no-git-tag-version
npm publish --tag next # users opt in with: npm install @acme/ui-kit@next
Installing in other projects. Consumers point the scope at the group endpoint, so every package in the group is reachable through one line:
build-app:
image: node:22-slim
script:
- echo "@acme:registry=${CI_API_V4_URL}/groups/42/-/packages/npm/" > .npmrc
- echo "${CI_API_V4_URL#*:}/groups/42/-/packages/npm/:_authToken=${CI_JOB_TOKEN}" >> .npmrc
- npm ci
The consuming project must be in the producing project's job token allowlist (last topic of this subchapter). Developers on laptops use the same .npmrc with a personal access token (read_api scope) instead of the job token.
Duplicates. The registry rejects a second publish of an existing name and version. Treat versions as immutable, and bump the version instead of republishing.
Key takeaways
- Use scoped names that match the top-level group (
@acme/...). - In CI, generate
.npmrcwith the project endpoint and_authToken=${CI_JOB_TOKEN}, and set the version from the tag. - Publish pre-releases under a separate dist-tag such as
next. - Install from the group endpoint, so one registry line covers all of the group's packages.
Practice
- (Beginner) Publish
@<your-group>/helloversion1.0.0from a tag pipeline, then install it in a second project's job.- (Intermediate) Extend the pipeline so that every
mainpipeline publishesX.Y.Z-dev.<pipeline IID>under the dist-tagdev, while tags publish tolatest. Show thatnpm installwithout a tag still gets the stable version.- Interview question: "Why should an internal npm package name be scoped, and what can go wrong if it is not?" Hint: which registry
npmasks first for an unscoped name, and who can publish there.
Publishing Maven Packages
Why it matters. Java and Kotlin teams share libraries as Maven artifacts: JARs with a pom.xml that declares their dependencies. GitLab's Maven endpoint works with standard mvn deploy and Gradle's maven-publish plugin, so no tool changes are needed. Only the repository URL and an authentication header change.
Maven: three pieces. The repository in pom.xml, a CI-specific settings.xml with the job token header, and the deploy job.
<!-- pom.xml (excerpt) -->
<groupId>com.acme</groupId>
<artifactId>payments-client</artifactId>
<version>1.4.0-SNAPSHOT</version>
<repositories>
<repository>
<id>gitlab-maven</id> <!-- must match the server id in settings.xml -->
<url>${env.CI_API_V4_URL}/groups/42/-/packages/maven</url> <!-- install dependencies from the group -->
</repository>
</repositories>
<distributionManagement>
<repository>
<id>gitlab-maven</id>
<url>${env.CI_API_V4_URL}/projects/${env.CI_PROJECT_ID}/packages/maven</url> <!-- publish to this project -->
</repository>
<snapshotRepository>
<id>gitlab-maven</id>
<url>${env.CI_API_V4_URL}/projects/${env.CI_PROJECT_ID}/packages/maven</url>
</snapshotRepository>
</distributionManagement>
<!-- ci_settings.xml: committed to the repository, contains no secret -->
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<servers>
<server>
<id>gitlab-maven</id>
<configuration>
<httpHeaders>
<property>
<name>Job-Token</name> <!-- header name the GitLab Maven API expects in CI -->
<value>${env.CI_JOB_TOKEN}</value> <!-- read from the job environment at run time -->
</property>
</httpHeaders>
</configuration>
</server>
</servers>
</settings>
stages: [build, test, publish]
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
MAVEN_CLI_OPTS: "--batch-mode --errors --settings ci_settings.xml"
cache:
key:
files: [pom.xml]
paths: [.m2/repository/]
deploy-snapshot:
stage: publish
image: maven:3.9-eclipse-temurin-21
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- mvn $MAVEN_CLI_OPTS deploy -DskipTests # publishes 1.4.0-SNAPSHOT (overwritable)
deploy-release:
stage: publish
image: maven:3.9-eclipse-temurin-21
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- mvn $MAVEN_CLI_OPTS versions:set -DnewVersion="${CI_COMMIT_TAG#v}" -DgenerateBackupPoms=false
- mvn $MAVEN_CLI_OPTS deploy -DskipTests # publishes 1.4.0 (a release version)
Gradle. With the maven-publish plugin, the job token goes into an HTTP header credential:
// build.gradle.kts (excerpt)
plugins {
`java-library`
`maven-publish`
}
publishing {
publications {
create<MavenPublication>("library") {
from(components["java"])
}
}
repositories {
maven {
name = "GitLab"
url = uri("${System.getenv("CI_API_V4_URL")}/projects/${System.getenv("CI_PROJECT_ID")}/packages/maven")
credentials(HttpHeaderCredentials::class) {
name = "Job-Token" // same header as with Maven
value = System.getenv("CI_JOB_TOKEN")
}
authentication {
create<HttpHeaderAuthentication>("header")
}
}
}
}
publish-gradle:
image: gradle:8.10-jdk21
rules:
- if: $CI_COMMIT_TAG
script:
- gradle publish -Pversion="${CI_COMMIT_TAG#v}" --no-daemon
Snapshots and duplicates.
| Version | Meaning | Republishing |
|---|---|---|
1.4.0-SNAPSHOT | Moving development build | Expected. Each deploy adds a timestamped file, and consumers get the newest |
1.4.0 | Fixed release | Allowed by default in GitLab. Disable it under Settings > Packages and registries > Duplicate packages so a release can never be overwritten |
Key takeaways
- Maven needs a matching
<id>inpom.xmlandsettings.xml, with theJob-Tokenheader set from${env.CI_JOB_TOKEN}. - Publish to the project endpoint and resolve dependencies from the group endpoint.
- Snapshots from the default branch, releases from tags with the version set from the tag name.
- Disable duplicates for release versions, so
1.4.0always means the same bytes.
Practice
- (Beginner) Publish a minimal Maven library as
1.0.0-SNAPSHOTfrommainand find it, with its POM and JAR, under Deploy > Package registry.- (Intermediate) Create a second project that depends on the library through the group endpoint. Make its build fail until you add it to the library's job token allowlist, then make it pass.
- Interview question: "A production incident is traced to a library version
2.3.1whose contents differ from what was tested. How could that happen in your registry, and what prevents it?" Hint: duplicate settings, who can publish, and where releases are built.
Publishing PyPI Packages
Why it matters. Python teams share internal libraries, CLI tools and SDKs as wheels. GitLab's PyPI endpoint accepts uploads from twine (or uv publish) and serves a "simple" index that pip understands, so internal packages install like public ones.
Versioning from the tag. Python versions follow PEP 440, which is close to, but not the same as, semantic versioning (section 8.4). Let the build backend take the version from Git so that it can never disagree with the tag:
# pyproject.toml
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "acme-utils"
dynamic = ["version"] # computed at build time
[tool.hatch.version]
source = "vcs" # tag v1.4.0 -> version 1.4.0; untagged commits get a .devN version
Publishing job.
stages: [build, test, publish]
publish-pypi:
stage: publish
image: python:3.12-slim
variables:
GIT_DEPTH: "0" # hatch-vcs needs the tags and history
TWINE_USERNAME: gitlab-ci-token # fixed user name for job token authentication
TWINE_PASSWORD: $CI_JOB_TOKEN
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
before_script:
- apt-get update && apt-get install -y --no-install-recommends git
- pip install build twine
script:
- python -m build # dist/acme_utils-1.4.0-py3-none-any.whl and .tar.gz
- python -m twine upload --repository-url "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi" dist/*
The same upload with uv:
uv build
uv publish --publish-url "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi" \
--username gitlab-ci-token --password "$CI_JOB_TOKEN"
Uploading a file name that already exists in the project is rejected, like on pypi.org. Bump the version instead.
Installing. pip reads the simple index, with credentials in the URL:
test-consumer:
image: python:3.12-slim
script:
- pip install --index-url "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/api/v4/groups/42/-/packages/pypi/simple" acme-utils==1.4.0
Dependency confusion. Two settings decide whether a name can be taken over by a public package:
| Setup | Risk |
|---|---|
--extra-index-url <gitlab> next to the default pypi.org | pip takes the highest version from either index. Anyone who publishes acme-utils 99.0 on pypi.org wins |
--index-url <gitlab> only, with package forwarding enabled | Names GitLab does not know are forwarded to pypi.org. Safe for names that exist in GitLab, but a typo or a missing package resolves publicly |
--index-url <gitlab> only, forwarding disabled for the group | Only internal packages resolve. Public dependencies must come through another proxy |
Prefer --index-url, consider disabling forwarding for groups with private package names (Settings > Packages and registries), and pin versions with hashes in lockfiles.
Key takeaways
- Build with
python -m buildand upload withtwine(oruv publish) to/projects/:id/packages/pypi, usinggitlab-ci-tokenand$CI_JOB_TOKEN. - Derive the version from the Git tag. Version control tools need
GIT_DEPTH: "0". - Install from the group
simpleindex with--index-url. --extra-index-urlinvites dependency confusion. Understand package forwarding before relying on it.
Practice
- (Beginner) Publish a package with one function from a
v0.1.0tag and install it in another job withpip install --index-url ....- (Intermediate) Make the
mainbranch publish.devNversions automatically withhatch-vcs, and show thatpip install acme-utilsskips them whilepip install --pre acme-utilsinstalls them.- Interview question: "Explain a dependency confusion attack against a Python project that uses an internal registry, and how you would configure pip and GitLab to prevent it." Hint: how pip picks between indexes, and where unknown names are resolved.
Generic Packages
Why it matters. Not every deliverable fits a language ecosystem: compiled CLI binaries for three platforms, firmware images, a tarball of static assets, a database dump for test environments. Generic packages store any file under a name and version, with a stable download URL and no expiration. They are the usual home for release binaries (section 8.4).
Publish and download. A generic package is addressed by package name, version and file name. Uploading is a single PUT:
PUT /projects/:id/packages/generic/:package_name/:package_version/:file_name
GET /projects/:id/packages/generic/:package_name/:package_version/:file_name
stages: [build, publish]
build-cli:
stage: build
image: golang:1.23
parallel:
matrix:
- GOOS: [linux, darwin, windows]
GOARCH: [amd64, arm64]
script:
- CGO_ENABLED=0 go build -o "dist/acme-${GOOS}-${GOARCH}" ./cmd/acme
artifacts:
paths: [dist/]
expire_in: 1 day # only needed until the upload job runs
upload-cli:
stage: publish
image: curlimages/curl:8.10.1
needs: [build-cli] # all six matrix jobs
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
PACKAGE_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/acme-cli/${CI_COMMIT_TAG}"
script:
- |
for file in dist/*; do
curl --fail-with-body \
--header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "$file" \
"${PACKAGE_URL}/$(basename "$file")"
done
- cd dist && sha256sum * > SHA256SUMS && cd ..
- curl --fail-with-body --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file dist/SHA256SUMS "${PACKAGE_URL}/SHA256SUMS"
Downloading works with any token that can read the project:
curl --fail --location --header "PRIVATE-TOKEN: $TOKEN" --output acme \
"https://gitlab.example.com/api/v4/projects/12345/packages/generic/acme-cli/v1.4.0/acme-linux-amd64"
Naming rules. Package names and file names may contain letters, digits, dots, hyphens and underscores (file names also allow a few more characters, such as ~ and @). Use one package name per product and the release version as the package version. A package version can hold many files, as in the example above.
Useful options.
| Option | Effect |
|---|---|
?status=hidden on upload | The file is stored but not listed in the UI until you change its status. Useful while a multi-file upload is in progress |
?select=package_file on upload | The response contains the stored file's metadata (ID, size, SHA256) |
| Duplicate packages setting | Allowed by default. Disable it so a file in a released version cannot be replaced |
Artifacts or generic packages?
| Need | Use |
|---|---|
| Pass files to later jobs of the same pipeline | Artifacts |
| Short-term download for testers | Artifacts with expire_in |
| Permanent, versioned download, linked from a release | Generic package |
| Installation with a language package manager | npm, Maven, PyPI... packages |
Key takeaways
- Generic packages store any file under name, version and file name, with no expiration.
- Upload with
curl --upload-fileand theJOB-TOKENheader. Use--fail-with-bodyso upload errors fail the job. - Publish checksums alongside binaries and disable duplicates for released versions.
- Use artifacts to move files inside a pipeline and generic packages to distribute them.
Practice
- (Beginner) Upload
README.mdas generic packagedocsversion0.1.0, then download it withcurland a personal access token.- (Intermediate) Build a binary for two platforms with
parallel:matrix, upload both plus aSHA256SUMSfile in a tag pipeline, and verify the checksum of a downloaded binary.- Interview question: "Your release binaries are linked from the README as job artifact URLs, and users report broken links. What went wrong, and what would you change?" Hint: compare the lifetime and URL stability of artifacts and packages.
Authenticating with CI_JOB_TOKEN
Why it matters. Every publish and install in this subchapter needs a credential. Storing a personal access token as a CI/CD variable works, but it is long-lived, tied to one person, often overprivileged, and leaks badly. CI_JOB_TOKEN is created for each job, works only while the job runs, and carries the permissions of the user who started the pipeline. For package work it should be the default choice.
What the token can do. The job token is accepted by a limited set of API endpoints, including all package registries, the container registry, job artifacts, releases and a few others (section 5.1). With the package registry:
| Action | Same project | Another project |
|---|---|---|
| Install / read packages | Yes | Yes, if the other project's job token allowlist includes this project and the triggering user can read the other project |
| Publish packages | Yes, if the triggering user has at least the Developer role | Publish from the owning project instead. The token's scope is designed around its own project |
| Delete packages | No | No |
How each client sends it. Every ecosystem has its own authentication convention:
| Client | Where the token goes |
|---|---|
curl (generic, API) | Header JOB-TOKEN: $CI_JOB_TOKEN |
| npm | .npmrc: //host/path/:_authToken=${CI_JOB_TOKEN} |
| Maven / Gradle | HTTP header Job-Token, value ${env.CI_JOB_TOKEN} |
| twine, pip, uv | Basic authentication: user gitlab-ci-token, password $CI_JOB_TOKEN |
| NuGet, Composer, Helm | Basic authentication with user gitlab-ci-token |
The allowlist. Cross-project access is controlled by the target project. In the project that owns the packages, open Settings > CI/CD > Job token permissions and add the projects or groups that may use their job tokens against it (section 11.4 covers the security design):
acme/platform/payments-client (owns the package)
Job token permissions -> allowlist: acme/apps (group)
│
acme/apps/checkout job ──CI_JOB_TOKEN──> GET .../projects/<payments-client>/packages/maven/... 200 OK
acme/sandbox/demo job ──CI_JOB_TOKEN──> GET .../projects/<payments-client>/packages/maven/... 403 Forbidden
A 403 or 404 when installing from another project in CI, while the same command works on a laptop with a personal token, almost always means the allowlist is missing an entry.
Choosing a credential.
| Credential | Lifetime | Identity | Use when |
|---|---|---|---|
CI_JOB_TOKEN | One job | Triggering user, limited endpoints | Publishing and installing from GitLab CI (default) |
Deploy token (read_package_registry, write_package_registry) | Until revoked or expired | No user, bound to a project or group | Systems outside GitLab CI, such as a build farm or a Kubernetes cluster pulling packages |
| Project or group access token | Up to the configured expiry | A bot user with a role | CI tasks the job token cannot do, such as deleting packages or calling other APIs |
| Personal access token | Up to the configured expiry | A real person | Local development only. Avoid in CI |
Release safety. Because the job token acts as the triggering user, anyone who can run a pipeline on a ref can publish from it. Combine the job token with:
rulesthat publish only from protected tags or the default branch,- protected tags so that only maintainers can create release tags (section 3.1),
- disabled duplicates for release versions, and protected package rules where your GitLab version offers them, so that only selected roles can publish matching package names.
Key takeaways
CI_JOB_TOKENis short-lived, per job, and acts as the triggering user. Prefer it to stored tokens for package work.- Each client sends it differently:
JOB-TOKENheader, npm_authToken, MavenJob-Tokenheader, or basic authentication asgitlab-ci-token. - Reading from another project requires that project's job token allowlist to include yours.
- Use deploy tokens for systems outside CI and access tokens for actions the job token cannot perform.
Practice
- (Beginner) Print
curl -s -o /dev/null -w '%{http_code}' --header "JOB-TOKEN: $CI_JOB_TOKEN" <package URL>for a package in your own project and in another project. Explain both status codes.- (Intermediate) Replace a personal access token used for publishing in an existing pipeline with
CI_JOB_TOKEN. Then restrict publishing so that only pipelines for protectedv*tags can publish, and test it with an unprotected tag.- Interview question: "Why is a per-job token that inherits the triggering user's permissions safer than a stored bot token, and what new risk does it introduce?" Hint: blast radius when leaked versus who can trigger pipelines on which refs.
<a id="84-releases"></a>
8.4 Releases
A GitLab release is a permanent record attached to a Git tag. It holds a name, release notes, links to downloadable assets and source code archives that GitLab generates automatically. This subchapter shows how to create releases from pipelines, how to attach binaries and packages, how to compute versions automatically and how to generate release notes from the commit history.
release Keyword
Why it matters. A tag says which commit is version 1.4.0. Users also need to know what changed and where to download it. Creating that release entry by hand after every tag is slow and gets skipped. The release keyword makes it one more pipeline job, so every release is created the same way, from the same commit that was tested.
How it works. A job with a release section first runs its script, then creates the release through the Releases API, authenticated with the job token. The job image must contain the GitLab CLI (glab), which current GitLab versions use for this step. Older setups use the release-cli image, which is deprecated.
stages: [build, test, release]
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest # pin a specific version in production
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ # only for release tags
script:
- echo "Creating release for $CI_COMMIT_TAG" # script is required, even if trivial
release:
tag_name: $CI_COMMIT_TAG # the tag already exists: it started this pipeline
name: "Release $CI_COMMIT_TAG"
description: "Automated release of $CI_COMMIT_TAG. See CHANGELOG.md for details."
Keywords.
| Keyword | Required | Meaning |
|---|---|---|
tag_name | Yes | Tag of the release. If it does not exist, GitLab creates it from ref |
tag_message | No | Creates an annotated tag with this message when the tag is new |
name | No | Title shown on the release page (defaults to the tag name) |
description | Yes, in practice | Release notes as Markdown text, or a path to a file in the project directory, such as ./release_notes.md |
ref | No | Commit, branch or tag to create a new tag from. Defaults to $CI_COMMIT_SHA |
milestones | No | Milestone titles to link to the release |
released_at | No | ISO 8601 date. A future date marks an upcoming release, a past date backfills history |
assets:links | No | Download links (section below) |
Two ways to trigger a release.
Tag-first (tag pipeline) Release-first (default branch pipeline)
maintainer: git tag v1.4.0 && git push merge to main
│ │
tag pipeline: build ─> test ─> create-release main pipeline: version ─> build ─> test ─> create-release
(tag_name: $CI_COMMIT_TAG) (tag_name: v$VERSION, ref: $CI_COMMIT_SHA)
│
GitLab creates tag v1.4.0 ─> new tag pipeline starts
In the release-first flow, the version is computed by an earlier job and passed through a dotenv report (section 5.3). Because creating the tag starts a tag pipeline, the release job must exclude tag pipelines, or it would try to create the same release a second time:
stages: [build, test, release]
version:
stage: .pre
image: alpine:3.20
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- echo "VERSION=$(cat VERSION)" >> version.env # for example 1.4.0
artifacts:
reports:
dotenv: version.env
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest
needs:
- job: version
artifacts: true # receive $VERSION
rules:
- if: $CI_COMMIT_TAG
when: never # the tag pipeline this job creates must not repeat it
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- echo "Releasing v$VERSION from $CI_COMMIT_SHORT_SHA"
release:
tag_name: "v$VERSION"
ref: $CI_COMMIT_SHA # tag exactly the commit that was tested
name: "v$VERSION"
description: "Release v$VERSION"
Failure cases.
| Error | Cause | Fix |
|---|---|---|
| Release already exists | The job ran twice for the same tag, or the version was not bumped | Exclude tag pipelines in the release-first flow. Bump the version |
403 Forbidden creating the tag | The tag pattern is protected and the triggering user may not create it | Run the release from a pipeline started by an allowed user, or adjust the protected tag rule |
glab: command not found or release-cli: command not found | The job image lacks the release tool | Use the gitlab-org/cli image, or install glab in your own image |
Key takeaways
- A job with
releaseruns its script, then creates the release with the job token. The image must containglab(or the deprecatedrelease-cli). tag_nameis required. A missing tag is created fromref, which defaults to the pipeline's commit.- Tag-first releases run in tag pipelines. Release-first pipelines compute the version, create the tag, and must skip the tag pipeline they start.
descriptioncan be a file path, which lets an earlier job generate the notes.
Practice
- (Beginner) Add a
create-releasejob to a project, push tagv0.1.0, and find the release under Deploy > Releases together with the source code archives GitLab added.- (Intermediate) Implement the release-first flow with a
VERSIONfile and adotenvreport. Show what happens to the tag pipeline if you remove thewhen: neverrule.- Interview question: "Should a release be created from a tag pipeline or should the pipeline create the tag? Argue both sides." Hint: who decides the version, what is guaranteed to have been tested, and protected tag permissions.
Creating Releases with glab
Why it matters. The release keyword covers the standard case, but sometimes you need more control: creating a release from a script with conditional logic, uploading files directly, editing an existing release, or releasing from a laptop or from another CI system. The GitLab CLI, glab, uses the same API as the keyword and works anywhere.
Core commands.
| Command | Purpose |
|---|---|
glab release create <tag> [files...] | Create a release, creating the tag if needed, and optionally upload files |
glab release upload <tag> <files...> | Add files or links to an existing release |
glab release view <tag> | Show a release |
glab release list | List releases |
glab release download <tag> | Download a release's assets |
glab release delete <tag> | Delete a release (and, with --with-tag, the tag) |
# From a laptop (authenticated earlier with: glab auth login)
glab release create v1.4.0 \
--name "v1.4.0" \
--notes-file release_notes.md \
--ref main \
--milestone "1.4" \
./dist/acme-linux-amd64 ./dist/acme-darwin-arm64 # uploaded to the project and linked to the release
In a pipeline. In CI, glab can authenticate with the job token. Linking to files already published as generic packages (section 8.3) avoids re-uploading them:
stages: [build, test, release]
release-with-glab:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- glab auth login --job-token "$CI_JOB_TOKEN" --hostname "$CI_SERVER_HOST" --api-protocol "$CI_SERVER_PROTOCOL"
- PKG="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/acme-cli/${CI_COMMIT_TAG}"
- |
glab release create "$CI_COMMIT_TAG" \
--name "$CI_COMMIT_TAG" \
--notes-file release_notes.md \
--assets-links "[
{\"name\": \"Linux amd64\", \"url\": \"${PKG}/acme-linux-amd64\", \"link_type\": \"package\", \"direct_asset_path\": \"/bin/acme-linux-amd64\"},
{\"name\": \"Checksums\", \"url\": \"${PKG}/SHA256SUMS\", \"link_type\": \"other\", \"direct_asset_path\": \"/SHA256SUMS\"}
]"
The underlying API. Both the keyword and glab call the Releases API, which any HTTP client can use:
curl --fail-with-body --request POST \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
--header "Content-Type: application/json" \
--data "{\"tag_name\": \"$CI_COMMIT_TAG\", \"name\": \"$CI_COMMIT_TAG\", \"description\": \"Automated release\"}" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/releases"
Which tool to use.
| Approach | Strengths | Weaknesses |
|---|---|---|
release keyword | Declarative, visible in the YAML, validated by the linter | Fixed flow: script first, then release |
glab release in script | Conditional logic, uploads, edits, works outside GitLab CI | Errors appear only at run time |
Releases API with curl | No extra tool, any language | You build JSON and handle errors yourself |
Uploaded files (the [files...] arguments) are stored as project uploads, which have an instance-wide size limit (100 MB by default). Large binaries belong in generic packages, linked from the release.
Key takeaways
glab release createdoes everything the keyword does and adds file uploads, edits and use outside CI.- In CI, log in with
glab auth login --job-token "$CI_JOB_TOKEN". - Prefer linking to generic packages over uploading large files with the release.
- The keyword,
glabandcurlall use the same Releases API, so they can be mixed.
Practice
- (Beginner) Use
glab release createfrom your laptop to createv0.2.0with notes from a file, then view it withglab release view v0.2.0.- (Intermediate) Write a release job that creates the release with
glabonly if the tag message contains[release], and otherwise prints a message and exits successfully.- Interview question: "When would you choose
glabinscriptover thereleasekeyword, and what do you lose?" Hint: validation time, readability, flexibility.
Release Assets and Links
Why it matters. A release page is useful only if users can download what it describes. GitLab adds source code archives to every release automatically. Binaries, installers, container images and documentation must be attached as links. Done well, a link gives users a stable URL such as ".../releases/latest/downloads/acme-linux-amd64" that install scripts can rely on.
What a release contains.
Release v1.4.0
├── Source code (automatic): zip, tar.gz, tar.bz2, tar
├── Links (you add them)
│ ├── Linux amd64 package -> generic package file
│ ├── Container image image -> registry.gitlab.com/acme/cli:1.4.0
│ ├── Runbook runbook -> https://wiki.example.com/acme/deploy-1.4
│ └── Checksums other -> generic package file
└── Evidence (automatic): JSON snapshot of the release's milestones and issues
Link fields.
| Field | Meaning |
|---|---|
name | Label shown on the release page. Unique within the release |
url | Target URL (http, https or ftp). Unique within the release |
link_type | other (default), package, image or runbook. Groups the links in the UI |
direct_asset_path | Creates a permanent GitLab URL for the link (replaces the older filepath) |
Permanent download URLs. With direct_asset_path: /bin/acme-linux-amd64, two URLs redirect to the link's target:
# This release
https://gitlab.example.com/acme/cli/-/releases/v1.4.0/downloads/bin/acme-linux-amd64
# Always the latest release (the one with the most recent released_at)
https://gitlab.example.com/acme/cli/-/releases/permalink/latest/downloads/bin/acme-linux-amd64
An install script can therefore use one URL forever, while the actual file lives in the package registry:
curl -fsSL -o /usr/local/bin/acme \
"https://gitlab.example.com/acme/cli/-/releases/permalink/latest/downloads/bin/acme-linux-amd64"
chmod +x /usr/local/bin/acme
A complete release pipeline. Build, publish to the package registry, then release with links:
stages: [build, publish, release]
variables:
PACKAGE_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/acme-cli/${CI_COMMIT_TAG}"
build:
stage: build
image: golang:1.23
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- GOOS=linux GOARCH=amd64 go build -o dist/acme-linux-amd64 ./cmd/acme
- GOOS=darwin GOARCH=arm64 go build -o dist/acme-darwin-arm64 ./cmd/acme
artifacts:
paths: [dist/]
expire_in: 1 day # temporary: the package registry keeps the real copies
publish:
stage: publish
image: curlimages/curl:8.10.1
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- |
for f in dist/*; do
curl --fail-with-body --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file "$f" "$PACKAGE_URL/$(basename "$f")"
done
release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- echo "Releasing $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
description: "Release $CI_COMMIT_TAG"
assets:
links:
- name: "Linux amd64"
url: "$PACKAGE_URL/acme-linux-amd64"
link_type: package
direct_asset_path: /bin/acme-linux-amd64
- name: "macOS arm64"
url: "$PACKAGE_URL/acme-darwin-arm64"
link_type: package
direct_asset_path: /bin/acme-darwin-arm64
- name: "Container image"
url: "$CI_PROJECT_URL/container_registry" # registry page listing the $CI_COMMIT_TAG image
link_type: image
Adding links later. Links can be added to an existing release through the API, for example when a slow signing step finishes after the release was created:
curl --fail-with-body --request POST \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
--data name="Signature" \
--data url="$PACKAGE_URL/acme-linux-amd64.sig" \
--data link_type="other" \
--data direct_asset_path="/bin/acme-linux-amd64.sig" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/releases/$CI_COMMIT_TAG/assets/links"
Do not link to job artifacts. A link to .../-/jobs/8812/artifacts/raw/dist/acme breaks as soon as the artifact expires (section 8.1). Link to generic packages, the container registry or another permanent location.
Key takeaways
- Source code archives are added automatically. Everything else is a link with a name, URL and type.
direct_asset_pathgives a permanent URL per release, andpermalink/latestalways points at the newest release.- Store files in the package registry and link to them. Artifact links break when artifacts expire.
- Links can be added after creation through the release links API.
Practice
- (Beginner) Add a
runbooklink and apackagelink to a release created by thereleasekeyword, and check how the release page groups them.- (Intermediate) Publish two releases with the same
direct_asset_path, then show that thepermalink/latestURL serves the newer one. Change the older release'sreleased_atto the future and observe what happens.- Interview question: "Design how users install the latest version of your internal CLI with one command, without the URL changing between versions." Hint: where the bytes live,
direct_asset_path, and the latest-release permalink.
Semantic Versioning in Pipelines
Why it matters. A version number is a promise to consumers. With semantic versioning, 2.0.0 warns of breaking changes, 1.5.0 adds features, and 1.4.1 only fixes bugs, so dependency ranges such as ^1.4.0 can upgrade safely. If people choose numbers by feel, that promise breaks. A pipeline can enforce the format, compute the next version, and pass it to every job that needs it.
The format.
1 . 4 . 2 - rc.3 + build.857
│ │ │ │ │
MAJOR MINOR PATCH pre-release build metadata (ignored for ordering)
breaking new bug 1.4.2-rc.3 < 1.4.2
change feature fix
Ecosystems spell pre-releases differently, so translate where needed:
| Concept | SemVer / npm | Python (PEP 440) | Maven |
|---|---|---|---|
| Release | 1.4.2 | 1.4.2 | 1.4.2 |
| Release candidate | 1.4.2-rc.3 | 1.4.2rc3 | 1.4.2-RC3 |
| Development build | 1.5.0-dev.857 | 1.5.0.dev857 | 1.5.0-SNAPSHOT |
Where the version comes from.
| Strategy | Who decides | How CI gets it | Good for |
|---|---|---|---|
| Tag-driven | A person pushes v1.4.2 | CI_COMMIT_TAG | Small teams, infrequent releases |
| File-driven | A person edits VERSION or package.json in an MR | Read the file, then create the tag (release-first flow) | Version changes reviewed like code |
| Commit-driven | Commit messages (Conventional Commits) | A tool computes the next version from commits since the last tag | Frequent automated releases |
Validating tags. In the tag-driven flow, reject tags that are not valid versions before anything is published. GitLab's rules:if uses RE2 regular expressions:
.release-tag: &release-tag
if: '$CI_COMMIT_TAG =~ /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/'
publish:
rules:
- *release-tag # v1.4.2 and v1.5.0-rc.1 match; v1.4, 1.4.2 and v01.4.2 do not
script: ./publish.sh
Computing the next version from commits. With Conventional Commits, fix: means patch, feat: means minor, and ! or BREAKING CHANGE: means major. A small script can compute the next version from the commits since the last tag:
#!/usr/bin/env bash
# ci/next-version.sh: writes NEXT_VERSION to version.env for a dotenv report
set -euo pipefail
last_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0")
IFS=. read -r major minor patch <<< "${last_tag#v}"
patch=${patch%%-*} # drop a pre-release suffix such as -rc.1
if [ "$last_tag" = "v0.0.0" ]; then range="HEAD"; else range="${last_tag}..HEAD"; fi
subjects=$(git log "$range" --format='%s%n%b')
if grep -qE '^[a-z]+(\([^)]*\))?!:|^BREAKING CHANGE:' <<< "$subjects"; then
major=$((major + 1)); minor=0; patch=0
elif grep -qE '^feat(\([^)]*\))?:' <<< "$subjects"; then
minor=$((minor + 1)); patch=0
else
patch=$((patch + 1))
fi
echo "NEXT_VERSION=${major}.${minor}.${patch}" | tee version.env
stages: [build, test, release]
next-version:
stage: .pre
image: alpine/git:2.45.2
variables:
GIT_DEPTH: "0" # full history: the default shallow clone may miss the last tag
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- apk add --no-cache bash
- git fetch --tags --force # make sure every tag is present locally
- bash ci/next-version.sh
artifacts:
reports:
dotenv: version.env # NEXT_VERSION for later jobs
build:
needs: [next-version]
script:
- echo "Building $NEXT_VERSION"
- make build VERSION="$NEXT_VERSION"
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest
needs: [next-version, build]
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script: echo "Releasing v$NEXT_VERSION"
release:
tag_name: "v$NEXT_VERSION"
ref: $CI_COMMIT_SHA
description: "Release v$NEXT_VERSION"
Using a dedicated tool. For anything beyond this sketch, use an established tool. semantic-release with the @semantic-release/gitlab plugin computes the version, generates notes, creates the tag and the release, and can publish npm packages. It pushes tags with Git, so it needs a project access token (scopes api and write_repository) stored as a masked, protected variable named GITLAB_TOKEN:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/gitlab"
]
}
stages: [build, test, release]
semantic-release:
stage: release
image: node:22-slim
variables:
GIT_DEPTH: "0"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- apt-get update && apt-get install -y --no-install-recommends git ca-certificates
script:
- npx --yes semantic-release@24 # reads GITLAB_TOKEN from the environment
Versions for non-release builds. Builds from branches and MRs also need unique, sortable versions that can never be mistaken for releases. Combine the next version with predefined variables:
| Pipeline | Example version |
|---|---|
Tag v1.4.2 | 1.4.2 |
| Default branch | 1.5.0-dev.857+a1b2c3d (CI_PIPELINE_IID, CI_COMMIT_SHORT_SHA) |
Merge request !42 | 1.5.0-mr42.861 |
Key takeaways
- SemVer encodes compatibility: MAJOR for breaking changes, MINOR for features, PATCH for fixes. Pre-release suffixes sort before the release.
- Decide who owns the version: a tag, a file, or commit messages. Each leads to a different pipeline shape.
- Validate release tags with a regular expression in
rules, and pass computed versions to later jobs with adotenvreport. - Version computation needs the full history and the tags:
GIT_DEPTH: "0"andgit fetch --tags. - Translate pre-release formats per ecosystem (PEP 440, Maven
SNAPSHOT).
Practice
- (Beginner) Write a
rules:ifthat acceptsv2.0.0andv2.1.0-rc.1but rejects2.0.0,v2.0andrelease-2. Test it by pushing each tag.- (Intermediate) Use
ci/next-version.shin a pipeline. Merge afix:, then afeat:, then afeat!:commit and check that the releases created are a patch, a minor and a major bump. Then make the script skip the release when there are no new commits since the last tag.- Interview question: "Your team argues about whether a renamed configuration option is a minor or a major change. How do you make that decision repeatable, and how does the pipeline enforce it?" Hint: define the public API, Conventional Commit types, and review of the commit message.
Changelog Generation
Why it matters. Release notes answer the user's first question: "what changed, and does it affect me?" Written by hand at release time, they are late, incomplete or missing. Generated from structured information captured when each change is merged, they are complete and cost nothing at release time.
GitLab's built-in changelog. GitLab can build a changelog from Git trailers: a Changelog: line at the end of a commit message that names the category of the change.
Add rate limiting to the login endpoint
Requests are limited to 10 per minute per IP address.
Changelog: added
Common categories are added, fixed, changed, deprecated, removed, security, performance and other. Commits without the trailer are left out. For merge request-based workflows, put the trailer into the squash or merge commit message, for example through the project's commit message templates.
The changelog API collects all commits with trailers between the previous version's tag and the new version:
| Request | Effect |
|---|---|
GET /projects/:id/repository/changelog?version=1.4.0 | Returns the generated notes as JSON (notes field) without changing anything. Ideal for release descriptions |
POST /projects/:id/repository/changelog with version=1.4.0 | Generates the notes and commits them to CHANGELOG.md on a branch (the default branch unless branch is set) |
GitLab finds the previous version by parsing tags with a regular expression, which defaults to versions such as 1.3.0 or v1.3.0. Configure it, the category titles and the output template in .gitlab/changelog_config.yml:
# .gitlab/changelog_config.yml
date_format: "%Y-%m-%d"
tag_regex: '^v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$' # named groups are required
categories:
added: New features
fixed: Bug fixes
security: Security fixes
performance: Performance improvements
The generated Markdown looks like this:
## 1.4.0 (2026-10-15)
### New features (2 changes)
- [Add rate limiting to the login endpoint](acme/api@a1b2c3d...) ([merge request](acme/api!128))
- [Export reports as CSV](acme/api@e4f5a6b...) ([merge request](acme/api!131))
### Bug fixes (1 change)
- [Fix timezone in invoice dates](acme/api@0c9d8e7...) ([merge request](acme/api!133))
Using it in the release job. The changelog endpoints are not available to the job token, so use a project access token with the api scope, stored as a masked variable:
stages: [build, test, release]
release-notes:
stage: release
image: alpine:3.20
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- apk add --no-cache curl jq
- |
curl --fail-with-body --header "PRIVATE-TOKEN: $CHANGELOG_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/changelog?version=${CI_COMMIT_TAG#v}" \
| jq -r '.notes' > release_notes.md
artifacts:
paths: [release_notes.md]
expire_in: 1 day
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/cli:latest
needs: [release-notes]
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script: echo "Releasing $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
description: ./release_notes.md # file path, read from the downloaded artifact
Locally, glab changelog generate --version 1.4.0 prints the same notes, which is useful for previewing them before tagging.
Committing the changelog file. To keep a CHANGELOG.md in the repository, call the POST endpoint after a release. GitLab creates the commit itself, so the job does not need to push with Git:
curl --fail-with-body --request POST \
--header "PRIVATE-TOKEN: $CHANGELOG_TOKEN" \
--data "version=${CI_COMMIT_TAG#v}" \
--data "branch=$CI_DEFAULT_BRANCH" \
--data "message=Add changelog for ${CI_COMMIT_TAG}" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/changelog"
That commit starts a new pipeline on the default branch. Make sure that pipeline does not release again, for example by checking $CI_COMMIT_MESSAGE in rules or by computing "no new changes" in the version job.
Alternatives.
| Tool | Input | Output | Notes |
|---|---|---|---|
| GitLab changelog API | Changelog: trailers | Markdown via API, optional commit | No extra tool, configured in .gitlab/changelog_config.yml |
semantic-release | Conventional Commit messages | Release notes, tag and release | Also computes the version (previous topic) |
git-cliff | Conventional Commits or custom regex | Markdown from a template (git cliff --latest) | Single binary, runs fully offline |
Hand-written CHANGELOG.md with an "Unreleased" section | Edited in each MR | The file itself | Best wording, but depends on discipline. Enforce it with an MR check |
Key takeaways
- Capture changelog information at merge time, with commit trailers or Conventional Commits, not at release time.
- GitLab's changelog API reads
Changelog:trailers since the previous version tag.GETreturns notes,POSTcommits them toCHANGELOG.md. .gitlab/changelog_config.ymlcontrols the tag regex, category titles and template.- Generate notes in a job, pass them as an artifact, and use the file path in
release:description. - A changelog commit starts a new pipeline. Guard against releasing twice.
Practice
- (Beginner) Make three commits with
Changelog: added,Changelog: fixedand no trailer, tagv0.3.0, and preview the notes withglab changelog generate --version 0.3.0. Note which commit is missing.- (Intermediate) Build the
release-notesandcreate-releasejobs shown above with custom category titles in.gitlab/changelog_config.yml. Then add a job that commitsCHANGELOG.mdafter the release without starting a second release.- Interview question: "Developers keep forgetting changelog entries. How would you make them nearly impossible to forget without slowing down every merge request?" Hint: where the information is captured, MR templates, commit message templates, and a lightweight pipeline check.
<a id="9-reusable-and-modular-configuration"></a>