Skip to content

title: phpboyscout/cicd v0.11.5 — interruptible gate jobs + cache churn/key reduction description: Reduce CI runner churn and disk pressure on the self-hosted runner. (D1) Mark every MR/branch quality-gate job interruptible: true so a superseding push auto-cancels the in-flight pipeline instead of running both to completion. (D2) Demote terminal cache jobs (govulncheck, cargo-doc) from pull-push to pull so the cache tarball is not needlessly re-archived. (D3) Switch component cache keys from lockfile-hashed to a stable per-project key so the Docker executor reuses one cache volume per project instead of minting a new multi-GB volume per lockfile state. status: approved date: 2026-06-22 authors: [Matt Cockayne] tags: [spec, cicd, components, performance, churn, interruptible, cache]


Spec: phpboyscout/cicd v0.11.5 — interruptible gate jobs + cache-write reduction

  • Repository: gitlab.com/phpboyscout/cicd
  • Released as: v0.11.5 (patch — perf: no input/interface change, behaviour is strictly churn-reducing).
  • Driver: portfolio CI review (2026-06-22). All phpboyscout pipelines run on a single self-hosted runner (runner1, 192.168.0.4 — 4 vCPU / 4.6 GiB RAM). Runner capacity is the binding constraint, so the cheapest win is to stop spending it on work that a newer push has already obsoleted, and on re-archiving caches no job reads.

Decisions

D1 — interruptible: true on every quality-gate job

GitLab auto-cancels a running pipeline when a newer pipeline supersedes it on the same ref only if every still-running job is interruptible (and Auto-cancel redundant pipelines is enabled on the project, which is the default). Today only rust-tool-base sets interruptible (via its default:); every other consumer runs the superseded pipeline to completion on each force-push to an open MR — pure waste on a single shared runner.

Mark interruptible: true on the MR/branch gate jobs the components own, so consumers inherit it on the next Renovate bump with no per-repo change:

  • go-lint: golangci-lint
  • go-test: go-test, go-test-e2e
  • go-security: govulncheck, trivy, gitleaks, osv-scanner, analyze
  • rust-lint: rustfmt, clippy
  • rust-test: test-linux, test-macos, test-windows, test-integration, coverage
  • rust-security: cargo-deny, cargo-audit, trivy, gitleaks
  • rust-docs: cargo-doc
  • tofu-lint: tofu-fmt, tflint, terraform-docs-drift
  • tofu-validate: tofu-validate
  • tofu-security: trivy-config, checkov, gitleaks
  • zensical-pages: zensical-build

Explicitly NOT interruptible

Jobs whose mid-flight cancellation would lose work or leave state half-applied keep the default (interruptible: false):

  • tofu-plan (banks tfplan.cache consumed by ref-mode apply), tofu-apply (mutates real infrastructure)
  • tofu-module-publish, goreleaser (publish release artifacts)
  • release-plz (maintains the Release MR / cuts tags — a release run must complete; left non-interruptible)
  • renovate-self (scheduled dependency run)
  • zensical-pages pages deploy job (only the zensical-build job is interruptible; the deploy is not)

These run on main/tag/schedule pipelines that are not normally superseded, so the protection costs nothing while removing a footgun.

releaser-pleaser is already interruptible: true (idempotent, guarded by resource_group — mirrors apricote's upstream component) and is left as-is.

D2 — Demote terminal cache writers from pull-push to pull

Several jobs share a single files: [go.sum] / files: [Cargo.lock] cache key with policy: pull-push. Because the consumer stage order is lint → test → security/docs, these jobs run sequentially, each pulling the previous writer's cache, then re-archiving the (growing) tarball on exit. The job at the end of the chain re-uploads a cache that nothing downstream in the pipeline consumes — wasted archive CPU/IO on a 4-core box.

Demote the terminal consumers to policy: pull:

  • go-security govulncheck — runs in security (after go-test's pull-push already wrote the warm go.sum cache); it only reads.
  • rust-docs cargo-doc — runs in docs (after rust-test's test-linux wrote the Cargo.lock-keyed target/); it only reads.

Cache warmers keep pull-push (they seed downstream jobs): go-lint, go-test, rust-lint clippy, rust-test test-linux. The rust-test integration/coverage jobs are already pull.

This is intentionally conservative — it removes the one redundant re-archive at the tail of each chain without disturbing the warm-cache path. A deeper split (separate module-cache vs build-cache keys) is deferred.

D3 — Stable per-project cache keys (cap volume proliferation)

The Docker executor creates one cache volume per distinct cache key and never garbage-collects them. With key: { files: [go.sum] } / { files: [Cargo.lock] } the key is a hash of the lockfile, so every lockfile state — each branch, each Renovate bump, accumulated over weeks — mints a fresh multi-GB volume that is never reused or reclaimed. On the single self-hosted runner this filled the disk: at discovery there were ~40 orphaned cache volumes (~28 GB), worsened by a docker-prune timer that had been failing silently for days (it ran docker system prune --volumes --filter "until=24h", an invalid flag combination).

Switch every component cache to a stable per-project key:

  • Go (go-lint, go-test, go-security): key: "${CI_PROJECT_PATH_SLUG}-go"
  • Rust (rust-lint, rust-test, rust-docs): key: "${CI_PROJECT_PATH_SLUG}-cargo"

The runner now reuses and overwrites one volume per project per toolchain regardless of lockfile churn. Correctness is unaffected: Go's build cache and Cargo's fingerprints detect stale entries inside the archive and rebuild only what changed. Trade-off: that single archive grows slowly as dependency sets evolve (GitLab never prunes within a cache), but it is bounded per-project and handled by the runner's threshold-gated GC — versus the previous unbounded growth across every lockfile state.

$CI_PROJECT_PATH_SLUG (not $CI_COMMIT_REF_SLUG) is deliberate: a per-branch key would re-introduce volume-per-branch proliferation. Feature branches share the project cache, which on a single runner also maximises warm-cache reuse.

D4 — Versioning

Additive, interface-preserving, churn-reducing → perf(cicd):v0.11.5 (patch). No input added or changed (the cache key is component-internal); consumers pick it up via the Renovate preset.