Skip to content

improvement(ci): collect idle node_modules sticky disks at 3 days - #7513

Merged
waleedlatif1 merged 2 commits into
stagingfrom
ci/reclaim-node-modules-disks
Sep 5, 2026
Merged

improvement(ci): collect idle node_modules sticky disks at 3 days#7513
waleedlatif1 merged 2 commits into
stagingfrom
ci/reclaim-node-modules-disks

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

What's going on

The Docker layer-cache caps we shipped last week worked — layer caches gave back 431 GB. But total storage barely moved, because node_modules sticky disks grew +350 GB over the same period and ate the win. They're now 44 disks / 601 GB (~$305/mo) and the single largest storage line, with total storage back over the 500 GB quota.

Why they grew — checked, not assumed

Two possible causes, and they need different fixes:

  • Disks accumulating cruft over their life → the fix would be in test-build.yml, not retention.
  • More disks alive at once → the fix is retention.

It's the second. Disks created yesterday are already 14.8 GB, and the whole population now clusters tightly at 14.7–14.9 GB rather than the bimodal spread it had before. They're born that size — no accumulation. What changed is churn: bun.lock moved 24 times in 14 days on staging alone, and disks are created at ~6.3/day.

Steady state is new disks/day × GB/disk × retention:

predicted observed
7d (today) 606 GB 601 GB
3d (this PR) 260 GB

The model reproduces reality to within 1%, so the projection is trustworthy.

Why 3 days

Not a judgement call — the data has a cliff:

Retention Disks kept GB kept Reclaimed now
7d 41 558 43 GB
4d 34 458 144 GB
3d 20 277 348 GB
2d 19 263 362 GB

Going 4d→3d reclaims another 204 GB; going 3d→2d reclaims only 14 GB more. So 3 days captures essentially all of the win, and it clears a long weekend — a PR whose last CI run was Friday afternoon still hits a warm disk on Monday.

Expected: ~$173/mo steady-state, ~$176/mo reclaimed on the first run.

Why age-based and never PR-triggered

The key contains no PR identifier. Every open PR whose checkout has the same bun.lock mounts the same disk — there are far more open PRs than distinct lockfile hashes (28 hashes across 44 disks), so sharing is the common case. A pull_request: [closed] hook would destroy a disk many other open PRs are actively using, most aggressively for the most-shared disk.

Safety — every guard was executed, not reasoned about

Test Result
Normal run at 3d against the live listing 24/44 selected, 348 GB
retention_days: 0 refused
retention_days: "3d" (non-numeric) refused
Every disk crafted to look idle refused — that's a listing/clock fault, not idleness
A turbo-cache-build-push key injected into the listing 0 selected

Two independent filters: a server-side --search, then a local regex re-proving the full key shape. The event segment is [a-z_]+ rather than an enumerated push|pull_request — the key interpolates ${{ github.event_name }}, and a workflow_dispatch disk already exists that an enumerated list would have skipped forever.

Other choices worth a look in review:

  • GitHub-hosted runner, so collection keeps working during a Blacksmith outage or CI_PROVIDER break-glass switch — exactly when disks idle and still bill.
  • CLI pinned by version + SHA256, verified against the vendor's published .sha256 sidecar, not curl | sh; the job holds an org-wide token. Auto-update disabled so the pin holds.
  • permissions: {}, no checkout.
  • Group by key before the staleness test, since delete without --arch removes every arch variant.

Worst case of a wrong delete is one cold bun install — and the bun cache disk is not lockfile-keyed, so it never idles, is never collected, and a refill links from a warm store with no network fetch.

Setup required before this does anything

  1. A GitHub org admin runs blacksmith org-token create --label ci-stickydisk-gc --organization simstudioai (printed once).
  2. Add it as repository secret BLACKSMITH_CLI_TOKEN. The job fails with a clear message if it's missing.
  3. Must land on mainschedule only runs the default-branch copy.
  4. First run via Actions → Sticky Disk GC → Run workflow → dry_run = true; confirm every listed key is a node-modules-<hash> one.

Type of Change

  • Improvement

Testing

actionlint clean including its shellcheck pass. The selection logic was executed against the live account listing and against crafted listings for each guard (table above). Pinned CLI URL returns 200 and its SHA256 matches the published sidecar. Diff is one YAML file with no JS, so the repo audits run in CI.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

node_modules sticky disks are keyed on hashFiles('bun.lock') by design, so a
lockfile bump orphans the old disk. Blacksmith already evicts after 7 days of
inactivity, so this is not a leak — it is a retention window far too generous for
a key that churns this fast.

Steady state is (new disks/day x GB/disk x retention). Measured on the live
account: 6.3 new disks/day at 13.7 GB each, which at 7 days predicts 606 GB
against 601 GB actually observed, so the model holds. At 3 days it predicts
260 GB, and 24 of the 44 current disks (348 GB) are already past that.

3 days rather than 2 because the data says 2 buys almost nothing: only 14 GB
more is idle at 2 days than at 3, while 3 clears a long weekend so a PR whose
last CI run was Friday afternoon still hits a warm disk on Monday.

Age-based, never PR-triggered. The key holds no PR identifier, so every open PR
whose checkout has the same bun.lock mounts the same disk, and there are far more
open PRs than distinct lockfile hashes — sharing is the common case. Deleting on
PR close would destroy a disk many other open PRs are using.

Guards, all exercised against real and crafted listings before shipping: a
server-side --search plus a local regex re-proving the full key shape; an
[a-z_]+ event segment rather than an enumerated push|pull_request, because the
key interpolates github.event_name and a workflow_dispatch disk already exists
that an enumerated list would have skipped forever; a refusal when every disk
looks idle, which means a listing or clock fault rather than genuine idleness;
and rejection of a non-numeric or zero retention. A turbo key injected into the
listing is not selected.

Runs on a GitHub-hosted runner so collection still works during a Blacksmith
outage or a CI_PROVIDER break-glass switch, which is exactly when disks idle and
still bill. The CLI is pinned by version and SHA256 checked against the vendor's
published sidecar rather than piped from a remote installer, since the job holds
an org-wide token, and auto-update is disabled so the pin holds. Deletes fail the
job rather than continue-on-error.

Requires a BLACKSMITH_CLI_TOKEN repository secret; run once with dry_run first.
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
docs Ready Ready Preview Sep 5, 2026 3:25am UTC

Request Review

@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds scheduled and manually dispatchable garbage collection for node_modules sticky disks that have been idle beyond a configurable retention period.

  • Pins and verifies the Blacksmith CLI before authenticating with an organization token.
  • Strictly filters and groups eligible disk keys before evaluating staleness.
  • Supports fork pull-request keys and bases the bulk-delete safeguard on the grouped eligible set.
  • Provides dry-run reporting and fails clearly on unsafe inputs or deletion failures.

Confidence Score: 5/5

The PR appears safe to merge; no outstanding correctness, security, or repository-rule issues were identified.

Both previous findings were manually resolved, and the current implementation addresses them: fork keys match the producer’s exact suffix placement, while staleness selection and the bulk-delete safeguard now derive from the same filtered, grouped set.

Important Files Changed

Filename Overview
.github/workflows/stickydisk-gc.yml Adds guarded age-based sticky-disk collection; the follow-up changes correctly cover fork keys and align the bulk-delete safeguard with grouped eligible keys.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Scheduled or manual trigger] --> B[Install checksum-verified Blacksmith CLI]
  B --> C[Authenticate]
  C --> D[List node_modules sticky disks]
  D --> E[Validate key shape and group architecture variants]
  E --> F[Select keys whose newest variant exceeds retention]
  F --> G{Would every eligible key be deleted?}
  G -->|Yes| H[Refuse bulk deletion]
  G -->|No| I{Dry run?}
  I -->|Yes| J[Report candidates]
  I -->|No| K[Delete each stale key]
  K --> L[Fail workflow if any deletion fails]
Loading

Reviews (2): Last reviewed commit: "fix(ci): collect fork disks, and count l..." | Re-trigger Greptile

Comment thread .github/workflows/stickydisk-gc.yml Outdated
Comment thread .github/workflows/stickydisk-gc.yml Outdated
Two review findings, both real.

Fork PR disks were never collected. test-build.yml appends `-fork` after the
event name, so a fork key reads `pull_request-fork-<hash>`, and the `[a-z_]+`
event class cannot span that hyphen. `-fork` is now its own optional segment.
The live account has no fork disks right now, which is why testing the filter
against real data did not catch it.

The bulk-delete guard could never fire. It compared grouped deletion keys against
raw sticky-disk entries, so architecture variants — deliberately grouped into one
key — and entries the regex had already rejected both inflated the denominator.
The listing is now reduced to the deletable set once, and the staleness test and
the guard both count that same set.

Verified against the live listing and crafted ones: a stale fork key is selected;
a key with two arch variants collapses to a single delete; a key whose newest
variant is fresh is not selected; the guard fires when every eligible key is
stale even with an arch variant present; and an injected turbo key is still never
selected.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@waleedlatif1
waleedlatif1 merged commit 788669a into staging Sep 5, 2026
29 checks passed
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