Skip to main content
System.Return('/blog')
4 min read

The Slim CI Rebuild: One Pipeline Per Push, One Browser Download, Zero Redundant Installs

Every push to an MR branch ran two GitLab pipelines and every Playwright job downloaded its own Chromium. Commit 1f3a658 fixed all three with workflow rules, lockfile-keyed caching, and a shared browser artifact — here is the exact diff and what it cannot prove.

Every push to an MR branch ran the pipeline twice: once for the branch, once for the MR. That doubled GitLab Free runner-minute burn on the exact pushes where CI matters most. Each of the three Playwright jobs then downloaded its own ~150 MB Chromium. And two audit jobs each paid a full npm ci to run npm audit.

Commit 1f3a658 (ci: slim pipeline) fixed all three structurally. No benchmarks were captured before/after. What follows is the diff and the mechanism, not measured minutes.

Fix 1: one pipeline per push

# .gitlab-ci.yml — the whole dedup
workflow:
  auto_cancel:
    on_new_commit: interruptible
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - when: never

Branch pipelines for non-main branches are dropped. Nothing else is lost because this repo has no tags, schedules, or merge trains. MR and main pushes are the only ones that gate or release anything. The old shape (image: node:24, seven stages incl. audit/security/lint/typecheck/test) is collapsed to four: validate → build → verify → deploy.

Fix 2: lockfile-keyed cache

cache:
  key:
    files:
      - package-lock.json
    prefix: node-modules
  fallback_keys:
    - node-modules-
  paths:
    - node_modules/

The old key was ${CI_COMMIT_REF_SLUG}: cold on every new branch, exactly when CI matters most. A stale lockfile-keyed cache only ever costs minutes, never correctness, because every consumer still runs a full npm ci. Jobs with no node tooling (semgrep-sast, secret_detection, the three Playwright verify jobs) set cache: [] explicitly so they skip the restore entirely.

Fix 3: one Chromium download, one audit install

variables:
  PLAYWRIGHT_BROWSERS_PATH: "$CI_PROJECT_DIR/.pw-browsers"

The build job runs the single npx playwright install chromium (Darwin-guarded: --with-deps is apt-only and fails on the mac-shell runner); the artifact .pw-browsers/ travels to the verify jobs, which top up system libs only (~30–60 s) instead of re-downloading. And the two audit jobs (info + critical, each with its own npm ci) merged into one npm-audit running both levels.

The Playwright side needed a matching change in playwright.config.ts:

command: process.env.PW_USE_EXISTING_BUILD
  ? 'npx serve dist/client -p 4321 --no-clipboard'
  : 'npm run build && npx serve dist/client -p 4321 --no-clipboard',

CI sets PW_USE_EXISTING_BUILD=1 because dist/client already arrives via the build artifact. Rebuilding there wastes ~2–3 runner minutes. Unset locally, the config rebuilds hermetically as before.

The stage map after the rebuild

validate holds npm-audit (informational, allow_failure: true), semgrep-sast, secret_detection, lint, and typecheck (blocking since bundle-4 fixed 48 astro check errors; the config comments say NEVER relax it). build installs once, installs Chromium once, runs npm run build, and artifacts dist/, .csp-nonce, .vercel/output/, node_modules/, .pw-browsers/. verify holds csp-check, transitions-check, and test: all thin (cache: [], needs: [build]), all blocking. deploy runs finalize-csp.mjs then vercel deploy --prebuilt.

Also fixed in the same commit: the test job had been allow_failure: true, so a fully-red suite (including both axe-core accessibility specs) still produced a green pipeline. Since test is in deploy-production’s needs with when: on_success, a red suite now actually stops the release.

Honest limitations

  • No measured before/after times. I did not capture runner-minute totals or per-job durations before the rebuild, so “saves N minutes” would be invented. The savings are structural (one pipeline not two, one Chromium not three, three npm ci not five). Count the eliminated redundant work, not a stopwatch I never ran.
  • The ~150 MB and ~30–60 s figures are the comments’ own estimates in .gitlab-ci.yml, not measured in this repo’s runner logs.
  • Single-runner assumption. Sharing node_modules/ and .pw-browsers/ via artifacts assumes the mac-shell tag pool; a multi-runner setup would need cache-fallback tuning.

Maintainers on metered CI runners (GitLab Free minutes, single Mac shells) should care, because the failure this rebuild prevents is death by duplication: two pipelines per MR push, three Chromium downloads per run, five npm ci installs for one tree. Each invisible in isolation, each burning the minutes that gate your releases. Without the dedup, the deeper rot is the allow_failure: true test job: a fully-red suite, including the axe-core accessibility specs, still shipping green, so the pipeline’s approval means nothing. Structural savings need no stopwatch. Eliminating a redundant pipeline, two redundant browser downloads, and two redundant installs is arithmetic, not benchmarking. The gates this pipeline runs include the build attestation checks, and the stack contract is in the GitLab CI + Playwright/Vitest notes.

Related: the uses entry for this exact stack (GitLab CI + Playwright/Vitest), and the TweakDeals case study whose prebuild-data.ts empty-DB gate is the deploy-time sibling of these CI gates.

Share this log: Share on X Share on LinkedIn Share on Reddit

Related logs

↑↓ navigate Tab complete Enter open Esc close View all →