Skip to content

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”

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:

LayerCurrent stateGap
pnpm storeactions/setup-node@v6 with cache: pnpm on 8 workflowsNone — this is the canonical pattern per pnpm.io; do not stack a second actions/cache on top.
Turbo build cacheNone persisted across runspnpm build (= turbo run build) recomputes every task in every workflow. 7–15 min/run on deploy.yml / preview.yml.
Terraform providerssetup-terraform@v3 runs naked terraform init in 3 workflowsCloudflare 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 + DockerfileWorks, 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 versionlint-seo.yml pins setup-node@v4 and checkout@v4Drift from the v6 baseline used elsewhere.
Matrix install duplicationwrangler-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 binarysecurity-scan.yml downloads the release tarball every run10–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.


#Decision
C1Adopt 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.
C2Adopt 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.
C3Defer remote Turbo cache. Rejected (2026-05-27, measured) — see 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.
C4Defer docker-smoke.yml type=gha migration. Accepted (2026-05-27) — see 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.
C5Bump 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.
C6Defer the wrangler-dryrun.yml matrix-install restructuring. Rejected (2026-05-27, measured) — see 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.
C7Do 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.
C8Keep 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.
C9Cache the gitleaks binary in security-scan.yml keyed on GITLEAKS_VERSION.

Ordered by (impact × diff size). Each step is independently mergeable; no step depends on a later step landing.

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 transparently

Required 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.

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 build

actions/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.

  • C5 — lint-seo.yml: bump actions/checkout@v4 → @v6 and actions/setup-node@v4 → @v6.
  • C9 — Cache the gitleaks binary:
    - name: Cache gitleaks
    id: cache-gitleaks
    uses: actions/cache@v4
    with:
    path: /usr/local/bin/gitleaks
    key: gitleaks-${{ env.GITLEAKS_VERSION }}
    - name: Install gitleaks
    if: steps.cache-gitleaks.outputs.cache-hit != 'true'
    # existing curl + tar + mv steps

  • Cache node_modules/ directly. Rejected (C7a). pnpm uses a content-addressable store + symlinked isolated node_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, and TF_PLUGIN_CACHE_DIR covers them safely.
  • Remote Turbo cache (Vercel / rharkor/caching-for-turbo / R2 self-host). Rejected after measurement (C3, 2026-05-27). Post-merge baseline shows local actions/cache already 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. See research/ci-caching-baseline.md §5.
  • Migrate docker-smoke to cache-from/to: type=gha. Accepted (C4, 2026-05-27). Rather than rewriting docker compose up --build as docker buildx bake, docker-smoke.yml now pre-builds the workspace image via docker/build-push-action@v6 with load: true, and docker compose up --wait reuses the locally-tagged image — keeps compose authoritative for local dev while letting CI use BuildKit’s GHA cache backend. See research/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.

  • Per-workflow Terraform key prefixes (C1). HashiCorp’s docs explicitly call out TF_PLUGIN_CACHE_DIR concurrency as “undefined.” Two concurrent terraform init runs sharing the same cache key risk corruption. Distinct prefixes per workflow + workflow-level concurrency: 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 list if 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-dir mismatch (C2). Default Turbo cache is node_modules/.cache/turbo, which pnpm install wipes. We pin cacheDir in turbo.json to .turbo/cache so the cache survives across pnpm install runs and stays at a path actions/cache can address.
  • Corepack version mismatch. Keep packageManager pinned in root package.json; do not also set version: on pnpm/action-setup to a different string. action-setup wins and Corepack errors otherwise.
  • docker compose up --wait + non-zero exits (C4). Once --build is removed, compose’s --wait propagates any container exit as a job failure — even an immediate exit 0 one-shot — unless the container reached Healthy via its healthcheck first. The prior --build flow buffered stripe-cli’s exit 1 because the build delay let stripe-cli’s healthcheck land before the exit. Without --build, the race goes the other way. docker-smoke.yml works around this by enumerating workspace mailpit explicitly on docker 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 smoke up line too — or give it a healthcheck that lands before the exit.

After each phase lands, confirm:

  1. 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.
  2. Wall-clock delta. Record before/after Build, terraform init, gitleaks-install step durations on a representative branch.
  3. 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 via restore-keys.
  4. No state leakage. A terraform-drift run scheduled at the same time as a PR preview apply must not corrupt either’s plugin cache — distinct key prefixes (C1) ensure this; verify by running both concurrently once on a feature branch.
  5. CI green. All 7 protected checks on main pass 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.

WorkflowCold runWarm runDelta
deploy.yml (total)26497495054 — 3 m 56 s26497702666 — 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 providerfirst run primed keyHIT on terraform-deploy-Linux-X64-9ea50c…provider re-download skipped
Turbo cache keyfirst run primedHIT 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.

Captured on PR #160’s docker-smoke runs.

WorkflowCold runWarm runDelta
docker-smoke.yml (total)baseline --build path on main (26370101596) — 3 m 26 sC4 warm rerun (26506181115) — 1 m 36 s−53 %
docker/build-push-action@v6 stepcold 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.


  • 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.yml both if: false) — only the cache hook is pre-staged here (pnpm/action-setup@v4 + cache: pnpm on lighthouse-ci.yml’s setup-node@v6; coverage-comment.yml already had the latter). Full audit against C1–C9 still belongs to the respective re-enable PRs.

Positive.

  • ~7–15 min/run saved on deploy.yml and preview.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-node and checkout version parity across the workflow fleet.

Negative.

  • One turbo.json change (add cacheDir) — 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: pnpm continues to be the single source of truth.

Curated from the 2026-05-27 research pass (Terraform / Turborepo / pnpm-Wrangler-Docker tracks).

Terraform

Turborepo

pnpm / Node / Wrangler / Docker