Why did GitLab rebuild after a warm agent session?
I keep seeing that question under merge requests.
The scratch disk was hot. The pipeline was not.
Did the agent lie? Usually no.
Did GitLab break cache on purpose? Also no.
You compared two different machines and called them one.
The claim I keep hearing
Someone pastes a chat snippet into the MR.
"Dependencies were cached. Tests were instant. Ship it."
Then gitlab-ci pulls a cold image and rebuilds node_modules.
Sound familiar?
That gap is not a GitLab bug.
It is a identity problem you can print.
What a cache actually is
GitLab cache is not "the disk felt warm."
It is a key, a set of paths, and a policy.
The runner restores that key into a job image.
Miss any one of those three, and you get a cold build.
A free coding box can stay warm anyway.
Warmth is not a cache key.
I sometimes draft job YAML with MonkeyCode's free model access and free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. That box is a scratch workspace. It is not a GitLab runner.
Three things people mash together
- The agent's working directory on a free server
- GitLab
cache:keypluspaths - GitLab
artifactsthat expire after the pipeline
Those are three storage stories.
Treat them as one story and reviews go sloppy.
Want proof? Print them side by side.
Claim 1: "The scratch box cache is GitLab cache"
What people repeat: the agent restored deps, so CI will too.
What actually happened: the box reused leftover files on local disk.
No cache:key was uploaded. No runner restored it.
Correct model: GitLab cache is an object named by a key string.
If the key changes, the runner starts empty.
A leftover folder on a scratch box does not get a name.
# .gitlab-ci.yml — identity lives in the key, not the vibe
test:
image: node:22.12.0-bookworm
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push
script:
- npm ci
- npm test
Change package-lock.json one line?
GitLab should miss. The scratch box might still hit.
That is expected. Do not call it a regression.
Claim 2: "Artifacts are just cache with extra steps"
What people repeat: if the agent saved dist/, CI has dist/.
What actually happened: cache is a hint for later jobs.
Artifacts are pipeline outputs with expiry and who can download.
Correct model: cache can vanish. Artifacts are the handoff.
A scratch box file is neither until GitLab stores it.
Ask this in review: who downloads this path tomorrow?
If the answer is "the next job," you want artifacts: or needs:.
If the answer is "maybe npm," you want cache:.
build:
image: node:22.12.0-bookworm
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 day
test:
needs: ["build"]
image: node:22.12.0-bookworm
script:
- test -d dist
- npm test
See needs? That is order plus files.
The agent running commands top to bottom is not needs.
Order in a chat log is not a DAG.
Claim 3: "Same language runtime means same image"
What people repeat: Node 22 locally, so the job is fine.
What actually happened: the scratch box used whatever image it had.
GitLab used image: from YAML, or the default executor image.
Correct model: the job image is part of cache identity.
Native addons built on Ubuntu 24 will break on Alpine.
"It compiled in chat" does not pin a digest.
# What did Git actually pin?
git show HEAD:.gitlab-ci.yml | sed -n '/image:/p'
# What is on this laptop right now?
node -v
python3 --version
# Those two answers can both be true and still disagree.
Pin a tag you can explain.
node:latest is a moving target, not a receipt.
The agent will happily keep it because the build passed once.
Claim 4: "The agent already walked the DAG"
What people repeat: jobs ran in order in the session, so needs is optional.
What actually happened: one process ran a shell script.
GitLab may shard jobs across runners. Stages can skip. Rules can drop a job.
Correct model: rules, only, needs, and workflow decide the graph.
A linear chat transcript cannot encode rules:if.
If you skip the graph, you skip the failure mode.
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
rspec:
rules:
- if: '$CI_MERGE_REQUEST_LABELS =~ /skip-tests/'
when: never
- when: on_success
Did the agent honor skip-tests?
Did it even see labels? Ask before you merge.
Claim 5: "A green scratch build proves the cache key"
What people repeat: duration dropped, therefore the key is correct.
What actually happened: the box never deleted node_modules.
Duration dropped because the disk was dirty.
Correct model: a cache key is proven by a miss, then a hit.
You need two runner jobs, not one warm shell.
Dirty disks make heroes. They also hide key bugs.
Artifact: print a cache receipt before you comment
I want a file I can paste under the YAML diff.
Not a vibe. A table of keys, paths, images, artifacts.
This helper is a line scanner. It is not GitLab.
Label this as an unexecuted template until you run it locally.
It does not expand include:, extends:, or !reference.
It will not call the GitLab API. That is the point.
#!/usr/bin/env python3
"""ci_cache_receipt.py — stdlib scanner for .gitlab-ci.yml identity."""
from __future__ import annotations
import pathlib
import re
import sys
KEYS = (
("image", re.compile(r"^\s*image:\s*(.+)$")),
("cache_key", re.compile(r"^\s*key:\s*(.+)$")),
("cache_policy", re.compile(r"^\s*policy:\s*(.+)$")),
("cache_path", re.compile(r"^\s*-\s+(node_modules/|.*/)$")),
("artifact_path", re.compile(r"^\s*-\s+(dist/|build/|coverage/)$")),
("needs", re.compile(r"^\s*needs:\s*(.+)$")),
("expire_in", re.compile(r"^\s*expire_in:\s*(.+)$")),
)
def scan(text: str) -> dict[str, list[str]]:
found = {name: [] for name, _ in KEYS}
in_cache = False
in_artifacts = False
for raw in text.splitlines():
line = raw.rstrip()
if re.match(r"^\s*cache:\s*$", line):
in_cache, in_artifacts = True, False
continue
if re.match(r"^\s*artifacts:\s*$", line):
in_cache, in_artifacts = False, True
continue
if re.match(r"^\s*[a-zA-Z0-9_.-]+:\s*$", line) and not line.strip().startswith("-"):
if not line.strip() in {"key:", "paths:", "policy:", "reports:"}:
if not line.startswith(" ") and not line.startswith("\t"):
in_cache, in_artifacts = False, False
for name, pat in KEYS:
m = pat.match(line)
if not m:
continue
if name == "cache_path" and not in_cache:
continue
if name == "artifact_path" and not in_artifacts:
continue
found[name].append(m.group(1).strip() if m.lastindex else line.strip())
return found
def main() -> int:
path = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".gitlab-ci.yml")
if not path.is_file():
print(f"missing {path}", file=sys.stderr)
return 2
found = scan(path.read_text(encoding="utf-8"))
print(f"receipt for {path}")
for name, values in found.items():
uniq = list(dict.fromkeys(values))
print(f"- {name}: {uniq or ['(none parsed)']}")
print("- reminder: includes and extends are invisible here")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Run it on the file Git will merge, not on chat output.
python3 ci_cache_receipt.py .gitlab-ci.yml
git diff origin/main -- .gitlab-ci.yml
Optional, if you already use glab:
glab ci lint
Lint says the YAML parses.
It does not say the cache key will hit.
Do not confuse those lights.
Decision table I paste into MRs
| Claim in chat | Evidence that would count | Evidence that does not |
|---|---|---|
| Cache will hit on GitLab | Same cache:key, same paths, two runner jobs |
Warm ls node_modules on a scratch box |
Next job sees dist/
|
artifacts: plus needs: (or dependencies) |
Agent ran npm run build then tests |
| Runtime matches | Pinned image: tag or digest in YAML |
node -v from the agent's shell |
| Jobs run in order |
stages / needs / rules on the default branch |
Numbered steps in a chat log |
| Key is correct | First pipeline misses, second hits the same key | Duration dropped once on dirty disk |
Print that table. Then ask the author which cell they used.
If they point at chat duration, the mental model is still wrong.
If they point at two runner jobs, we can talk.
A tiny workflow that survives review
- Draft YAML anywhere you like, including a scratch box.
- Copy it into the branch. Commit it. Diff it.
- Run
ci_cache_receipt.pyonHEAD, not on a gist. - Fill the decision table with GitLab job URLs, not chat timestamps.
- Merge only when image, key, and paths are named in Git.
That is the whole method.
The agent can type. GitLab still names the bytes.
# Force a miss on your machine before you argue about CI.
rm -rf node_modules dist
# If your job uses npm ci, this is the honest baseline.
Did you delete the folder before celebrating speed?
If not, you measured dirt. Dirt is not a key.
Limitations
This scanner does not expand include: or extends:.
Nested templates will look empty. That is a limitation, not a feature.
It also does not resolve $CI_COMMIT_SHA inside keys.
It is not a secret scanner.
Do not paste CI_JOB_TOKEN, deploy keys, or protected variables into any agent box.
A receipt is public-safe strings: image names, path names, key files.
It cannot prove a runner will hit cache on shared runners.
Namespace, compression, and fallback keys still belong to GitLab.
Two green local runs do not bind the fleet.
Who should not use this
Skip this if you do not use GitLab CI at all.
Skip this if you need a supply-chain attestation. This is a checklist.
Skip this if you were about to replace runners with a free coding server.
Release managers should not gate on this script.
Protected branches still need real jobs, real images, real keys.
If your pipeline publishes packages, this article is not your control plane.
What I want in the next review comment
Stop writing "cached on the agent, should be fine."
Write the key files. Write the image tag. Write the artifact paths.
Ask whether the next job uses needs or a prayer.
The scratch box can be useful for drafts.
GitLab still owns cache identity.
If a free box helped you type the YAML, keep the receipt on the branch.
Top comments (0)