Skip to content

fix(ci): scope the Next.js build cache sticky disk per branch - #6072

Merged
waleedlatif1 merged 2 commits into
stagingfrom
fix/ci-nextjs-cache-branch-scope
Jul 30, 2026
Merged

fix(ci): scope the Next.js build cache sticky disk per branch#6072
waleedlatif1 merged 2 commits into
stagingfrom
fix/ci-nextjs-cache-branch-scope

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

The Turbopack persistent build cache sticky disk was keyed on github.event_name alone, so every open PR shared one mutable volume. Per Blacksmith's docs a sticky disk mount clones the last committed snapshot and the job commits back last-write-wins — so each PR build restored a Turbopack cache produced by a different branch.

Keys it per branch, reports whether the cache was actually warm, and deletes a branch's disk when its PR closes.

Evidence

Same staging commit, two runs minutes apart, identical runner — the only variable is which disk was mounted:

run event disk compile
30500234987 push …-nextjs-cache-push 9.3 min
30500235932 pull_request …-nextjs-cache-pull_request 14.0 min

Across 22 recent runs the split is clean: push builds 3.5–11 min, PR builds 13.5–17.8 min.

Why this is the correctness fix, not just a perf tweak

turbopackFileSystemCacheForBuild is beta, and restoring a cache built at a different commit is not a documented-supported mode. In vercel/next.js#87283 a user reports stale HTML in production from exactly that — the same commit producing different output depending on which cache was restored — and asks maintainers whether cross-build restore is supported at all. Unanswered. Others report build errors and 404s after branch switches.

Sharing one mutable Turbopack build cache across ~10 concurrent PR branches with last-write-wins is precisely that configuration, which means Build App may not faithfully reflect the branch under test.

Per-branch keying is also the documented pattern, not a workaround. Next.js's own CI-caching guide uses key: \${CI_COMMIT_REF_SLUG} (per-branch) in its GitLab example, and its GitHub Actions example only looks shared — GHA cache is branch-scoped by design ("cannot restore caches created for child branches or sibling branches"; PR caches are scoped to the merge ref). Blacksmith's own cache product branch-scopes by default too. This key was the only layer in the stack with no branch isolation.

What this does NOT fix

This will not get Build App back to 3 minutes. On the single-writer push disk — genuinely warm — compile has itself roughly doubled today:

date compile (staging push)
Jul 27–28 3.5–6.2 min
Jul 29 morning 4.9–6.9 min
Jul 29 ~14:50 PT onward 9.3–11.1 min

The inflection sits between the 11:54 PT and 14:50 PT builds. That regression is real and separate; it needs a bisect and is not addressed here.

Trade-offs

  • Sticky disks have no restore-keys fallback, so the first build on a new branch is fully cold. Given cross-commit restore is dubious anyway, cold-first is the safe behavior — and the new reporting gives us the actual cold number instead of a guess.
  • One-time cost: every currently-open PR gets one cold build on its next push.
  • Storage: per-branch disks at ~5 GB each are bounded by ci-cache-cleanup.yml on PR close. continue-on-error: true because PRs that skipped Build App via paths-ignore have no disk to delete.
  • Fork PRs are keyed by the fork's branch name, so two forks with the same branch name still share. Strictly better than today (all forks shared one disk), not perfect isolation.

Type of Change

  • Bug fix
  • Improvement

Testing

actionlint clean on both workflows (only the pre-existing unknown-blacksmith-*-label false positive). stickydisk-delete pinned to the real v1 commit SHA with its delete-key input verified against the action's action.yml.

This PR's own Build App run is the first check: it should report PRE_BUILD_CACHE=cold (new branch, new key), and a second push to this branch should report PRE_BUILD_CACHE=warm with a faster compile. That is the direct verification of the whole premise — worth confirming before merge.

The Turbopack persistent build cache disk was keyed on github.event_name
alone, so every open PR shared one mutable volume. A sticky disk mount
clones the last committed snapshot and commits back last-write-wins, so
each PR build restored a cache produced by a different branch.

Measured on the same staging commit, two runs minutes apart: the
single-writer push disk compiled in 9.3 min, the shared pull_request
disk in 14.0 min. Across 22 recent runs, push builds land at 3.5-11 min
and PR builds at 13.5-17.8 min.

Correctness matters more than the minutes here.
turbopackFileSystemCacheForBuild is beta and cross-commit restore is not
a documented-supported mode - vercel/next.js#87283 reports stale HTML
from a cache built at another commit, with no maintainer answer. Every
other cache layer in this repo already isolates by branch: GitHub's
cache cannot read sibling branches, and Blacksmith's cache product
branch-scopes by default. Only this key didn't.

Adds pre/post-build cache size reporting, because whether the cache was
warm is otherwise invisible - the disk mounts either way and turbo
buffers the build log. Adds ci-cache-cleanup.yml to delete a branch's
disk when its PR closes, so per-branch disks track open PRs instead of
accumulating at ~5 GB each.

Also corrects the 105s-cold/22s-warm comment: those were local numbers
and have never reproduced in CI.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Jul 30, 2026 12:24am

Request Review

@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Workflow-only changes to cache keys and cleanup; no application runtime impact, with cleanup keyed to match mounts and non-blocking on delete failures.

Overview
Scopes the Blacksmith Next.js build cache sticky disk per branch instead of sharing one mutable volume across all PRs. The Build App mount key in test-build.yml now appends github.head_ref (or github.ref_name on push), so each branch gets its own ~5 GB Turbopack cache and avoids last-write-wins restores from other branches/commits.

Adds ci-cache-cleanup.yml, which runs when a PR closes and deletes the matching pull_request sticky disk (same delete-key as the mount; continue-on-error for PRs that never built). Push disks on main/staging/dev are left alone.

Observability: pre- and post-build du steps report whether .next/cache was actually warm, without failing the job. Comments on NEXT_TURBOPACK_BUILD_CACHE are tightened to note local warm/cold numbers are not CI targets.

Reviewed by Cursor Bugbot for commit cb77e1e. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Scopes the Next.js/Turbopack build-cache sticky disk per branch and adds PR-close cleanup plus cache-size reporting.

  • test-build.yml: mount key now appends github.head_ref || github.ref_name (and keeps event/fork namespacing); pre/post-build du steps report cache warmth.
  • ci-cache-cleanup.yml: on pull_request closed, deletes the matching Blacksmith sticky disk key (non-blocking; push/main disks intentionally retained).

Confidence Score: 5/5

This PR appears safe to merge; no blocking failures remain from prior or current review.

No blocking failure remains.

Important Files Changed

Filename Overview
.github/workflows/test-build.yml Per-branch sticky-disk key for Next.js cache plus pre/post-build size reporting; no app runtime impact.
.github/workflows/ci-cache-cleanup.yml New PR-close job deletes the pull_request-scoped sticky disk with a key meant to match the mount key.

Reviews (2): Last reviewed commit: "refactor(ci): trim the cache-key comment..." | Re-trigger Greptile

Keeps the mechanism (one mutable volume per key, clone-on-mount,
last-write-wins) and the two numbers, drops the restated reasoning. Both
report steps collapse to a single du, dropping a second full walk of a
~5 GB tree. Cleanup workflow loses a concurrency group a once-per-PR
delete never needed.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1
waleedlatif1 merged commit 998fd5a into staging Jul 30, 2026
27 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/ci-nextjs-cache-branch-scope branch July 30, 2026 00:27

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cb77e1e. Configure here.

uses: useblacksmith/stickydisk-delete@b41313d28b8647d72114c9ba3c96bb04061562b6 # v1
continue-on-error: true
with:
delete-key: ${{ github.repository }}-nextjs-cache-pull_request${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ github.head_ref }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fork cleanup key can miss

Low Severity

The delete key relies on github.event.pull_request.head.repo.fork, but on pull_request closed that head.repo can be null when the source fork was deleted. The expression then omits the -fork suffix, so cleanup targets a different key than the mount used and the ~5 GB disk is left behind. continue-on-error hides the failed delete.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cb77e1e. Configure here.

uses: useblacksmith/stickydisk-delete@b41313d28b8647d72114c9ba3c96bb04061562b6 # v1
continue-on-error: true
with:
delete-key: ${{ github.repository }}-nextjs-cache-pull_request${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ github.head_ref }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sibling PR disk wiped early

Medium Severity

Cleanup deletes the Next.js sticky disk by head_ref on every PR close, but the mount key is also only head_ref-scoped. Closing one PR from a branch that still has another open PR (for example into main and staging, or a non-CI base) removes the shared volume the surviving PR still uses. The next build goes cold, and continue-on-error hides a wrong delete.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cb77e1e. Configure here.

waleedlatif1 added a commit that referenced this pull request Jul 30, 2026
…uilds) (#6080)

* perf(ci): disable the Turbopack persistent build cache

It is a net loss at this app's size. A controlled A/B on one branch (#6078),
three runs with a byte-identical module graph so only cache state varied:

  cache OFF        113s compile, 2m53s job
  cache ON, cold   162s compile, 3m54s job
  cache ON, warm   360s compile, 8m18s job

The cache made the same build 3.2x slower. It also grew 5.1 GB -> 12 GB
across two runs of an unchanged tree, which explains the progressive
degradation seen on longer-lived disks (up to 11.7 min): the more a disk is
written, the more the next run must read and revalidate. Flag manipulation
is visible in the logs — the cache-on runs print
`✓ turbopackFileSystemCacheForBuild`, the cache-off run omits it — and every
run used `turbo --force` so none is a replayed log.

#5869 enabled this on locally-measured numbers (105s cold -> 22s warm) that
never reproduced in CI and are inverted here. #6072 then branch-scoped the
disk to stop PRs restoring each other's caches; that fixed a real problem,
but with the cache off the disk is unnecessary, so the mount, the pre/post
size reporting, and the env gate all go with it.

Pins `turbopackFileSystemCacheForBuild: false` explicitly rather than relying
on the Next default: upstream already flips that default to true in
canary/preview builds (vercel/next.js#94616), so leaning on the default would
let a version bump silently re-enable this.

Keeps ci-cache-cleanup.yml, re-scoped to draining the 5-12 GB volumes that
PRs opened while the per-branch key was live still hold — nothing else
reclaims them. It is a no-op for new PRs and can be deleted once drained.

Caveat: n=1 per cell. The 3.2x effect size and agreement with ~15 prior
observations make it convincing, but this is three runs, not a distribution.

* docs(ci): correct the cleanup key comment after the mount was removed

Greptile P2: the delete step still claimed its key must stay byte-identical
to the Mount Next.js build cache step in test-build.yml, but this PR removes
that mount. It is now a hard-coded legacy drain key that mirrors nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant