I audited 10 famous GitLab projects with gitlab-ci-doctor. Here are the patterns.
gitlab-ci-doctor
is a 12-rule audit for .gitlab-ci.yml - cost, security, hygiene.
Published it tonight (homepage,
in-browser scanner).
This post is the immediate dogfood: I ran it against 10 well-known
public projects on gitlab.com to see which rules actually fire and
whether the patterns are real.
Short answer: the patterns are real. Even projects whose owners are the people who run gitlab.com leave free minutes on the floor.
Headline numbers
| Projects audited | 9 (of 10 attempted) |
|---|---|
| Total findings | 155 |
| Projects with at least one finding | 9 / 9 (100%) |
| Most common rule | missing-timeout (74 hits) |
Rules ranked by how often they fired
| Rule | Hits | What it catches |
|---|---|---|
missing-timeout | 74 | No timeout: means a runaway job runs to the project default. |
missing-interruptible | 48 | No interruptible: true means stale MR pipelines keep burning minutes. |
git-strategy-clone | 9 | GIT_STRATEGY: clone or unset GIT_DEPTH wastes bandwidth. |
missing-needs | 8 | Stages without needs: block on the entire previous stage. |
artifact-no-expiration | 7 | artifacts: without expire_in: accumulate storage cost. |
image-no-pin | 5 | image: should be pinned to a digest, not :latest or unspecified. |
parse-error | 2 | |
parallel-overcommit | 1 | parallel: > 8 multiplies job minutes; sanity-check the matrix. |
missing-cache | 1 | Jobs that install packages without a cache: re-download deps every run. |
Per-project results
gitlab-org/gitlab-runner
1 finding (1 warn / 0 info / 0 error) on main, 1,865 bytes.
git-strategy-clone@ line 1: GIT_DEPTH is unset or 0 - full history is fetched on every job. Set a small depth (e.g. 20) unless you need full history.
gitlab-org/gitaly
31 findings (31 warn / 0 info / 0 error) on master, 27,264 bytes.
missing-timeout@ line 218: Job 'build' defines no timeout. A runaway will burn the project default (often 1h, max 24h). Set a tight per-job cap.missing-timeout@ line 241: Job 'build:binaries' defines no timeout. A runaway will burn the project default (often 1h, max 24h). Set a tight per-job cap.missing-timeout@ line 264: Job 'test' defines no timeout. A runaway will burn the project default (often 1h, max 24h). Set a tight per-job cap.- ... and 28 more
gitlab-org/gitlab-pages
3 findings (3 warn / 0 info / 0 error) on master, 1,254 bytes.
missing-interruptible@ line 39: Job 'download deps' has no interruptible: true. New pipeline pushes will not cancel its stale predecessor. Easy ~30%+ minutes savings on busy MRs.missing-timeout@ line 39: Job 'download deps' defines no timeout. A runaway will burn the project default (often 1h, max 24h). Set a tight per-job cap.git-strategy-clone@ line 1: GIT_DEPTH is unset or 0 - full history is fetched on every job. Set a small depth (e.g. 20) unless you need full history.
gitlab-org/release-cli
1 finding (1 warn / 0 info / 0 error) on master, 1,274 bytes.
git-strategy-clone@ line 32: GIT_DEPTH is unset or 0 - full history is fetched on every job. Set a small depth (e.g. 20) unless you need full history.
gitlab-org/cli
51 findings (48 warn / 3 info / 0 error) on main, 19,264 bytes.
image-no-pin@ line 441: Job 'notify-issues-on-release' uses image 'registry.gitlab.com/gitlab-org/cli:latest' without a digest pin. Tag ':latest' is mutable. Pin to @sha256:... for reproducible, supply-chain-safe builds.image-no-pin@ line 467: Job 'windows_installer' uses image '${GITLAB_DEPENDENCY_PROXY}amake/innosetup:latest' without a digest pin. Tag ':latest' is mutable. Pin to @sha256:... for reproducible, supply-chain-safe builds.missing-cache@ line 250: Job 'lint_commit' runs '\bnpm (ci|install)\b' but defines no cache:. Each pipeline re-downloads node_modules. Adds 30-90s/job and bandwidth cost.- ... and 48 more
inkscape/inkscape
2 findings (0 warn / 0 info / 2 error) on master, 19,282 bytes.
parse-error@ line 340: Map keys must be unique at line 340, column 7: - if: $CI_CERT_CERTIFICATE if: $CI_CERT_KEY ^parse-error@ line 341: Map keys must be unique at line 341, column 7: if: $CI_CERT_KEY if: $CI_CERT_PASSWORD ^
gitlab-org/charts/gitlab
46 findings (41 warn / 5 info / 0 error) on master, 23,581 bytes.
image-no-pin@ line 292: Job 'trigger_review_current' uses image '${BUSYBOX_IMAGE}' without a digest pin. No tag specified - defaults to ':latest'. Pin to @sha256:... for reproducible, supply-chain-safe builds.image-no-pin@ line 323: Job 'trigger_review_secondary' uses image '${BUSYBOX_IMAGE}' without a digest pin. No tag specified - defaults to ':latest'. Pin to @sha256:... for reproducible, supply-chain-safe builds.image-no-pin@ line 691: Job 'issue-bot' uses image '${CI_REGISTRY}/${GITLAB_NAMESPACE}/distribution/issue-bot:latest' without a digest pin. Tag ':latest' is mutable. Pin to @sha256:... for reproducible, supply-chain-safe bui...- ... and 43 more
gitlab-org/container-registry
1 finding (1 warn / 0 info / 0 error) on master, 4,748 bytes.
git-strategy-clone@ line 130: GIT_DEPTH is unset or 0 - full history is fetched on every job. Set a small depth (e.g. 20) unless you need full history.
gitlab-org/api/client-go
19 findings (19 warn / 0 info / 0 error) on main, 8,997 bytes.
missing-interruptible@ line 118: Job 'golangci-lint' has no interruptible: true. New pipeline pushes will not cancel its stale predecessor. Easy ~30%+ minutes savings on busy MRs.missing-interruptible@ line 134: Job 'buf-lint' has no interruptible: true. New pipeline pushes will not cancel its stale predecessor. Easy ~30%+ minutes savings on busy MRs.missing-interruptible@ line 143: Job 'verify-generated-code' has no interruptible: true. New pipeline pushes will not cancel its stale predecessor. Easy ~30%+ minutes savings on busy MRs.- ... and 16 more
Skipped
gitlab-org/security-products/license-finder- no .gitlab-ci.yml on main or master
The boring lessons
1. missing-interruptible is the cheapest fix nobody applies
If you have any non-trivial test job, interruptible: true at the job (or default:) level is roughly free money. Push a fixup to a branch, the previous pipeline cancels itself, you stop paying for stale commits. Most of the projects that audited dirty miss this on at least their long-running stages.
2. missing-cache on the second install is the silent loss
Many .gitlab-ci.yml files cache npm ci in the build job and then a different job runs npm ci again with no cache key, paying full freight. gitlab-ci-doctor looks for the install command in the script body, not just the cache section, so it catches the per-job miss.
3. image-no-pin is the security hygiene every team is one supply-chain incident away from caring about
Pin to image: node@sha256:<digest>. Yes, you have to update it when you bump the image. That is the point: it is now a code change reviewers can see, not an upstream tag rewrite that happens at 3am.
4. parallel-overcommit is the spend nobody runs the math on
A parallel: 16 on a 2xlarge runner is 16x the rate, billed in full. If your test suite finishes in 5 minutes serially, the parallel split is just paying GitLab to do less work per minute. Fan out only when you can show a real wall-clock win.
Try the same audit on your own repo
$ npx gitlab-ci-doctor # in your repo, takes ~1 second $ npx gitlab-ci-doctor --markdown # MR-comment friendly $ npx gitlab-ci-doctor --json # machine-readable
Or paste a project path into the browser scanner. No signup, no upload, runs in your tab.
Drop it into your lint stage
Two-line job. MR-comment friendly. Exit code 1 on errors, so it gates merges if you want it to.
Setup snippet npm install SourceAlready on GitHub Actions? Same engine, sister tool.
ci-doctor ships 14 rules + auto-fix for GHA workflows. Same opinions, same low ceremony.