ADR-0003: CI caching strategy for GitHub Actions
ADR-0003: CI caching strategy for GitHub Actions
Section titled “ADR-0003: CI caching strategy for GitHub Actions”- Status: Accepted (2026-05-27)
- Deciders: @ArneFfm
- Master design impact: None. Augments spec 13 §7 (CI matrix) without amending any D-row.
- Augments:
spec 13 — Quality & Release Policy§7. - Related: ADR-0001 Hono, ADR-0002 Workers Static Assets for docs.
1. Context
Section titled “1. Context”The repo ships 14 GitHub Actions workflows (.github/workflows/*.yml). Most install pnpm deps; three run Terraform; two rebuild the Turbo graph; one runs Docker compose. A workflow-level audit (2026-05-27) found three classes of caching gaps that together waste ~500+ runner-minutes/week on a high-velocity monorepo:
| Layer | Current state | Gap |
|---|---|---|
| pnpm store | actions/setup-node@v6 with cache: pnpm on 8 workflows | None — this is the canonical pattern per pnpm.io; do not stack a second actions/cache on top. |
| Turbo build cache | None persisted across runs | pnpm build (= turbo run build) recomputes every task in every workflow. 7–15 min/run on deploy.yml / preview.yml. |
| Terraform providers | setup-terraform@v3 runs naked terraform init in 3 workflows | Cloudflare provider re-downloaded every run; 15–30 s/run × deploy, preview, terraform-drift. |
| Docker buildx (smoke) | actions/cache@v4 → /tmp/.buildx-cache keyed on pnpm-lock.yaml + Dockerfile | Works, but uses docker compose up --build (not docker/build-push-action), so the type=gha migration path requires a docker buildx bake rewrite first. Deferred. |
| Outdated action version | lint-seo.yml pins setup-node@v4 and checkout@v4 | Drift from the v6 baseline used elsewhere. |
| Matrix install duplication | wrangler-dryrun.yml runs pnpm install on 14 matrix legs (7 workers × 2 envs) | 2–5 min/PR of redundant install work; pnpm store cache covers most of it, restructuring is a measurable-effect bet. Deferred. |
| Gitleaks binary | security-scan.yml downloads the release tarball every run | 10–15 s/run, weekly + PR. |
The pnpm story is already correct and must not be regressed. Everything else is upside.
A note on what does not benefit from Turbo cache: workflows that call pnpm --filter <pkg> build (e.g. preview-docs.yml’s astro build, and deploy.yml’s docs-site rebuild on line ~222) invoke the package’s build script directly, not via turbo. actions/cache on .turbo is dead weight there. Only pnpm build (root script = turbo run build) benefits. In this repo that limits Turbo cache to deploy.yml and preview.yml.
2. Decisions
Section titled “2. Decisions”| # | Decision |
|---|---|
| C1 | Adopt a Terraform provider plugin cache in deploy.yml, preview.yml, terraform-drift.yml using TF_PLUGIN_CACHE_DIR + actions/cache@v4, keyed on infra/terraform/.terraform.lock.hcl. Each workflow uses a distinct key prefix to respect HashiCorp’s “concurrency is undefined” caveat. |
| C2 | Adopt a Turbo local cache via actions/cache@v4 on the two workflows that actually invoke pnpm build (= turbo run build): deploy.yml, preview.yml. Pin cacheDir in turbo.json so the cache lives at a stable workspace-relative path. Key: ${{ runner.os }}-turbo-${{ github.sha }} with restore-key prefix. |
| C3 | research/ci-caching-baseline.md §5. Post-merge measurement: GHA repo cache 5.88 GB / 10 GB (58.9 %), Turbo warm-replay hit-rate 100 % (28/28 packages) on deploy.yml run 26497702666. Local actions/cache@v4 already extracts the full speedup; a remote backend (rharkor/caching-for-turbo, R2 self-host) would add a third-party dependency + pinned-SHA maintenance for zero additional benefit at our volume. Revisit only if (a) cache usage crosses ~80 % of the cap with eviction-induced regressions, or (b) cross-machine cache sharing becomes a goal. |
| C4 | docker-smoke.yml type=gha migration.research/ci-caching-baseline.md §3. docker-smoke.yml now pre-builds nowyourlink-workspace:dev via docker/build-push-action@v6 with cache-from/to: type=gha,scope=smoke,mode=max and load: true. The subsequent docker compose up --wait (no --build) reuses the locally-tagged image and skips its own build. Replaces the previous actions/cache@v4 → /tmp/.buildx-cache rotate dance. |
| C5 | Bump lint-seo.yml setup-node from @v4 to @v6 and checkout from @v4 to @v6 for action-version parity with the rest of the fleet. |
| C6 | wrangler-dryrun.yml matrix-install restructuring.research/ci-caching-baseline.md §2. Post-merge measurement on run 26367151912: per-leg total 25 – 30 s, install + setup phase 15 – 19 s. Below the 30 s/leg threshold at which restructuring would earn its complexity. Matrix legs run in parallel; total workflow wall-clock is ~40 s gated by the slowest leg, not the sum. setup-node@v6 cache: pnpm already absorbs the duplicated install cost. Leaving the workflow shape alone. |
| C7 | Do NOT cache: (a) node_modules/ — anti-pattern with pnpm’s symlinked store. (b) .terraform/ directory — known stale-module bugs (terraform#30005, terraform#22488). (c) .wrangler/ — leaks stale account-id state; not worth caching. (d) esbuild output dir for Wrangler — no real cross-invocation cache exists in esbuild’s API. |
| C8 | Keep packageManager pinned in root package.json (currently pnpm@10.0.0, verified 2026-05-27) so pnpm/action-setup and Corepack agree; this is the version-mismatch footgun called out by actions/setup-node v5 regressions and Corepack issue #774. |
| C9 | Cache the gitleaks binary in security-scan.yml keyed on GITLEAKS_VERSION. |
3. Implementation plan
Section titled “3. Implementation plan”Ordered by (impact × diff size). Each step is independently mergeable; no step depends on a later step landing.
Phase 1 — Terraform plugin cache (C1)
Section titled “Phase 1 — Terraform plugin cache (C1)”Affects deploy.yml, preview.yml, terraform-drift.yml. Drop the snippet below in before the existing hashicorp/setup-terraform@v3 step. Bump the key prefix per workflow so a scheduled drift run and a PR preview run don’t write the same cache entry.
env: TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform.d/plugin-cache # (keep existing TF_IN_AUTOMATION etc.)
steps: # ...checkout...
- name: Create plugin cache dir run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
- name: Cache Terraform providers uses: actions/cache@v4 with: path: ${{ env.TF_PLUGIN_CACHE_DIR }} # NB: prefix differs per workflow (deploy / preview / drift) — see C1 rationale. key: terraform-deploy-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('infra/terraform/.terraform.lock.hcl') }} restore-keys: | terraform-deploy-${{ runner.os }}-${{ runner.arch }}-
- uses: hashicorp/setup-terraform@v3
# subsequent `terraform init` uses the plugin cache transparentlyRequired invariants: infra/terraform/.terraform.lock.hcl is committed (verified present 2026-05-27). Leave plugin_cache_may_break_dependency_lock_file unset (default false) so cross-OS/arch lockfile entries stay intact.
Phase 2 — Turbo local cache (C2)
Section titled “Phase 2 — Turbo local cache (C2)”Affects deploy.yml, preview.yml. Insert immediately after pnpm install --frozen-lockfile, before the pnpm build step.
Prerequisite: set Turbo’s cache dir explicitly so actions/cache and turbo agree. The current turbo.json (verified 2026-05-27) has no cacheDir; add it at top level alongside the existing tasks block:
{ "$schema": "https://turbo.build/schema.json", "cacheDir": ".turbo/cache", "tasks": { /* unchanged */ }}- name: Cache Turbo build artefacts uses: actions/cache@v4 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo-
- name: Build run: pnpm buildactions/cache only restores files; Turbo’s own per-task content hash decides hit/miss against the restored .turbo/cache/<hash>.tar.zst files, so prefix-restore is safe — Turbo will miss any task whose hash isn’t present and rerun it.
Phase 3 — Quick wins
Section titled “Phase 3 — Quick wins”- C5 —
lint-seo.yml: bumpactions/checkout@v4→@v6andactions/setup-node@v4→@v6. - C9 — Cache the gitleaks binary:
- name: Cache gitleaksid: cache-gitleaksuses: actions/cache@v4with:path: /usr/local/bin/gitleakskey: gitleaks-${{ env.GITLEAKS_VERSION }}- name: Install gitleaksif: steps.cache-gitleaks.outputs.cache-hit != 'true'# existing curl + tar + mv steps
4. Alternatives considered
Section titled “4. Alternatives considered”- Cache
node_modules/directly. Rejected (C7a). pnpm uses a content-addressable store + symlinked isolatednode_modules; restoring it from cache resurrects dangling symlinks. The pnpm store cache already covers the install hot path. - Cache the entire
.terraform/directory. Rejected (C7b). HashiCorp’s own issue tracker (terraform#30005, terraform#22488) documents stale-module manifest and backend-config drift. Modules are tiny; providers are where the time lives, andTF_PLUGIN_CACHE_DIRcovers them safely. - Remote Turbo cache (Vercel /
rharkor/caching-for-turbo/ R2 self-host). Rejected after measurement (C3, 2026-05-27). Post-merge baseline shows localactions/cachealready gets 100 % Turbo warm-replay hits with the GHA repo cache at 58.9 % of the 10 GB cap; a remote backend buys zero additional speedup at our volume. Seeresearch/ci-caching-baseline.md§5. - Migrate docker-smoke to
cache-from/to: type=gha. Accepted (C4, 2026-05-27). Rather than rewritingdocker compose up --buildasdocker buildx bake,docker-smoke.ymlnow pre-builds the workspace image viadocker/build-push-action@v6withload: true, anddocker compose up --waitreuses the locally-tagged image — keeps compose authoritative for local dev while letting CI use BuildKit’s GHA cache backend. Seeresearch/ci-caching-baseline.md§3. - Pre-build a custom runner image with cached Terraform/Turbo/pnpm. Rejected. Maintenance overhead vastly exceeds savings at our CI volume. Revisit only if we move to self-hosted runners.
- Wrangler-specific cache. Rejected. No official Cloudflare guidance for external CI (their build-caching doc covers Cloudflare Workers Builds only).
.wrangler/holds account-resolution state that’s actively unsafe to share across runs.
5. Footguns & invariants
Section titled “5. Footguns & invariants”- Per-workflow Terraform key prefixes (C1). HashiCorp’s docs explicitly call out
TF_PLUGIN_CACHE_DIRconcurrency as “undefined.” Two concurrentterraform initruns sharing the same cache key risk corruption. Distinct prefixes per workflow + workflow-levelconcurrency:groups eliminate the race. - Lockfile must be committed. Both
hashFiles('**/.terraform.lock.hcl')and pnpm cache keying depend on this. Verified present 2026-05-27. - OS/arch in cache keys. Both Terraform providers and esbuild binaries are platform-specific. If self-hosted runners (arm64) ever land alongside
ubuntu-latest(amd64), a restored cache from the wrong arch fails checksum. Always include${{ runner.os }}-${{ runner.arch }}for Terraform. - GHA cache 10 GB repo cap. Now raisable (Nov 2025 changelog) but billed beyond default. Turbo + buildx + pnpm-store + Terraform all share the cap. Monitor via
gh cache listif a workflow’s hit rate degrades — LRU eviction with 7-day idle retention is the usual culprit. - Fork PRs cannot write cache. GHA security boundary; reads from base-branch cache work, writes do not. First-time contributor PRs still pay the full cost on their first run — expected, not a defect.
- Turbo
--cache-dirmismatch (C2). Default Turbo cache isnode_modules/.cache/turbo, whichpnpm installwipes. We pincacheDirinturbo.jsonto.turbo/cacheso the cache survives acrosspnpm installruns and stays at a pathactions/cachecan address. - Corepack version mismatch. Keep
packageManagerpinned in rootpackage.json; do not also setversion:onpnpm/action-setupto a different string.action-setupwins and Corepack errors otherwise. docker compose up --wait+ non-zero exits (C4). Once--buildis removed, compose’s--waitpropagates any container exit as a job failure — even an immediateexit 0one-shot — unless the container reachedHealthyvia its healthcheck first. The prior--buildflow buffered stripe-cli’sexit 1because the build delay let stripe-cli’s healthcheck land before the exit. Without--build, the race goes the other way.docker-smoke.ymlworks around this by enumeratingworkspace mailpitexplicitly ondocker compose up, so stripe-cli (the only service that intentionally exits) is never started. If you add a new service to docker-compose.yml that exits, list it in the smokeupline too — or give it a healthcheck that lands before the exit.
6. Verification
Section titled “6. Verification”After each phase lands, confirm:
- Cache write on first run, cache hit on second. Check the workflow log for
Cache restored from key: …. A second run with no input changes must show a hit. - Wall-clock delta. Record before/after
Build,terraform init, gitleaks-install step durations on a representative branch. - Lockfile-bump invalidation. Manually bump
pnpm-lock.yaml(or.terraform.lock.hcl); the next run must show a miss on the exact key and a restore viarestore-keys. - No state leakage. A
terraform-driftrun scheduled at the same time as a PRpreviewapply must not corrupt either’s plugin cache — distinct key prefixes (C1) ensure this; verify by running both concurrently once on a feature branch. - CI green. All 7 protected checks on
mainpass without modification (no change to gating semantics — this ADR is purely runtime).
6.1 Phase 1 + 2 verification log (2026-05-27)
Section titled “6.1 Phase 1 + 2 verification log (2026-05-27)”Captured post-merge of PRs #157 and #158; full numbers in research/ci-caching-baseline.md.
| Workflow | Cold run | Warm run | Delta |
|---|---|---|---|
deploy.yml (total) | 26497495054 — 3 m 56 s | 26497702666 — 2 m 01 s | −49 % |
pnpm build (Turbo) | 124 s, 28/28 MISS | <1 s, 28/28 cache hit, replaying logs | −123 s |
terraform init provider | first run primed key | HIT on terraform-deploy-Linux-X64-9ea50c… | provider re-download skipped |
| Turbo cache key | first run primed | HIT on Linux-turbo-deploy-a79490d8… | primary, not restore-keys fallback |
Both C1 and C2 hit primary key, not the prefix restore-keys fallback — the implementation matches §3’s design.
6.2 C4 verification log (2026-05-27)
Section titled “6.2 C4 verification log (2026-05-27)”Captured on PR #160’s docker-smoke runs.
| Workflow | Cold run | Warm run | Delta |
|---|---|---|---|
docker-smoke.yml (total) | baseline --build path on main (26370101596) — 3 m 26 s | C4 warm rerun (26506181115) — 1 m 36 s | −53 % |
docker/build-push-action@v6 step | cold primer (no prior scope=smoke manifest): writes cache via #22 exporting to GitHub Actions Cache | #10 importing cache manifest from gha:… + 9 CACHED layers (#11–#19) | full BuildKit layer reuse, no rebuild of pnpm install layer |
stripe-cli is intentionally excluded from docker compose up --wait on this workflow — see §5 Footguns. The smoke probes only hit workspace (worker /healthz) and mailpit (/livez); stripe-cli requires real credentials and exits non-zero on the smoke stub, which --wait propagates as a job failure when not buffered by a --build-induced healthcheck race.
7. Out of scope
Section titled “7. Out of scope”- Test result caching (Vitest), coverage upload caching, Lighthouse CI baseline storage.
- Self-hosted runners.
- Caching across organisations or repos.
- Workflow-level concurrency groups (mentioned only as a concurrency safety net for C1; full concurrency-group design belongs to spec 13).
- Currently disabled workflows (
lighthouse-ci.yml,coverage-comment.ymlbothif: false) — only the cache hook is pre-staged here (pnpm/action-setup@v4+cache: pnpmonlighthouse-ci.yml’ssetup-node@v6;coverage-comment.ymlalready had the latter). Full audit against C1–C9 still belongs to the respective re-enable PRs.
8. Consequences
Section titled “8. Consequences”Positive.
- ~7–15 min/run saved on
deploy.ymlandpreview.yml(Turbo). - ~15–30 s/run saved on each Terraform workflow.
- gitleaks install drops from ~10–15 s/run to a near-zero cache hit on weekly + PR runs.
setup-nodeandcheckoutversion parity across the workflow fleet.
Negative.
- One
turbo.jsonchange (addcacheDir) — small, but a config-surface change. - 5 workflow files touched + 1 config file + 1 new ADR (7 files total).
- GHA cache cap consumption rises; monitor under load.
Neutral.
- pnpm caching pattern unchanged. setup-node@v6’s
cache: pnpmcontinues to be the single source of truth.
9. Sources
Section titled “9. Sources”Curated from the 2026-05-27 research pass (Terraform / Turborepo / pnpm-Wrangler-Docker tracks).
Terraform
- HashiCorp CLI Config —
plugin_cache_dir, concurrency caveat - hashicorp/setup-terraform#4 — Enabling plugin cache directory
- hashicorp/terraform#30005 — stale modules/providers after init
- hashicorp/terraform#22488 —
.terraform/cache prevents backend changes
Turborepo
- Turborepo — GitHub Actions guide
- Turborepo — Caching
- rharkor/caching-for-turbo (maintained GHA-proxy successor to dtinth’s unmaintained action)
- AdiRishi/turborepo-remote-cache-cloudflare
- GHA cache size limit changelog (Nov 2025)
pnpm / Node / Wrangler / Docker