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-lintgo-test: go-test, go-test-e2ego-security: govulncheck, trivy, gitleaks, osv-scanner, analyzerust-lint: rustfmt, clippyrust-test: test-linux, test-macos, test-windows, test-integration, coveragerust-security: cargo-deny, cargo-audit, trivy, gitleaksrust-docs: cargo-doctofu-lint: tofu-fmt, tflint, terraform-docs-drifttofu-validate: tofu-validatetofu-security: trivy-config, checkov, gitleakszensical-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(bankstfplan.cacheconsumed 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-pagespagesdeploy job (only thezensical-buildjob 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-securitygovulncheck — runs insecurity(aftergo-test'spull-pushalready wrote the warmgo.sumcache); it only reads.rust-docscargo-doc — runs indocs(afterrust-test'stest-linuxwrote theCargo.lock-keyedtarget/); 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.