Skip to content

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

Closed
waleedlatif1 wants to merge 1 commit into
stagingfrom
ci/gc-idle-node-modules-sticky-disks
Closed

improvement(ci): collect idle node_modules sticky disks at 3 days#7215
waleedlatif1 wants to merge 1 commit into
stagingfrom
ci/gc-idle-node-modules-sticky-disks

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

node_modules sticky disks are keyed on hashFiles('bun.lock') by design (see the load-bearing comment in test-build.yml), so every lockfile bump orphans the old disk. That design is correct and is preserved here.

This is not a leak. Blacksmith already evicts any sticky disk after 7 days of inactivity — confirmed in their docs and corroborated by the data: the oldest idle disk in the account is 7.50 days, with nothing beyond it. What we have is a retention window far too generous for a key that churns ~4.7 disks/day.

Measured across the 39 live disks: the median disk is used for 0.16 days, then billed for another 7. The retention tail is essentially the entire cost.

Retention Steady-state resident Cost
7d (today) ~236 GB ~$118/mo
3d (this PR) ~111 GB ~$56/mo
2d ~80 GB ~$40/mo

3 days rather than 2 so a long weekend still hits a warm disk: a PR whose last CI run was Friday afternoon and that gets pushed Monday morning is ~2.5 days idle. The extra day costs ~$16/mo and is worth it.

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 ~180 open PRs over roughly 20 distinct lockfile hashes, so sharing is the common case, not an edge case. A pull_request: [closed] hook would destroy a disk dozens of other open PRs are actively using, and would do so most aggressively for the most-shared disk. The existing ci-cache-cleanup.yml is safe only because its legacy key was ${{ github.head_ref }} — genuinely per-branch.

Safety

Two independent guards, because the blast radius of a wrong key is a cache every CI job depends on:

  1. Server-side --search '-node-modules-'.
  2. 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 (14 GB) that an enumerated list would have skipped forever. Caught by testing the selection against live data rather than reasoning about it.

Verified against the account: matches all 39 node_modules disks and none of the 19 bun/turbo/Docker disks (which are mounted every run, never idle, and must survive). Dry run selects 25 disks / 129 GB.

Other choices worth defending 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, not curl https://get.blacksmith.sh | sh; the job holds an org-wide token and must not execute unpinned remote shell. Checksum verified against the vendor's published .sha256 sidecar. BLACKSMITH_DISABLE_AUTO_UPDATE=1 so the pin holds.
  • permissions: {} and no checkout — the job reads nothing from the repo.
  • Group by key before the staleness test, since delete without --arch removes every arch variant.
  • Deletes fail the job, so a revoked token can't silently revert us to 7-day billing.

Worst case of a wrong delete is one cold bun install — and the bun cache disk is not lockfile-keyed, so it never idles and never gets collected, meaning a refill links from a warm local 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 (prints once).
  2. Add it as repository secret BLACKSMITH_CLI_TOKEN.
  3. This must land on mainschedule only runs the default-branch copy.
  4. First run via Actions → Sticky Disk GC → Run workflow → dry_run = true, and confirm every listed key is a node-modules-<hash> one.

Type of Change

  • Improvement

Testing

actionlint clean including its shellcheck pass. Selection logic dry-run against the live account (25 candidates / 129 GB) and cross-checked to exclude all 19 non-node_modules disks. CLI URL returns 200 and its SHA256 matches the published sidecar. bun run lint and all 38 check:audits pass.

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 ~4.7 disks/day. Measured across the 39 live disks, the median
one is USED for 0.16 days and then billed for another 7, so the retention tail is
almost the whole cost. Collecting at 3 days takes the family from ~236 GB to
~111 GB steady-state.

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 — with ~180 open PRs
over roughly 20 distinct lockfile hashes, sharing is the common case, and a
delete on PR close would destroy a disk dozens of other open PRs are using.

Two independent guards on what may be deleted, because the blast radius of a
wrong key is a cache every CI job depends on: 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. Verified against the live account: matches all 39 node_modules
disks and none of the 19 bun/turbo/Docker disks.

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 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, so
a revoked token cannot silently revert us to 7-day billing.

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

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
docs Building Building Preview Aug 28, 2026 9:08am

Request Review

@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a scheduled and manually dispatchable workflow that installs a pinned Blacksmith CLI and deletes node_modules sticky disks idle beyond a configurable retention window.

  • Filters candidate disks by repository key shape and groups architecture variants before evaluating staleness.
  • Supports dry runs, publishes a job summary, and fails when any deletion fails.
  • The current input validation and key matching leave destructive and coverage defects that should be corrected before rollout.

Confidence Score: 2/5

This PR should not merge until the destructive retention input is bounded and the collector correctly handles the existing fork-key namespace.

An authorized manual run can purge recently used shared caches through an unchecked cutoff, while normal fork-PR cache keys are systematically excluded from the intended three-day collection policy.

Files Needing Attention: .github/workflows/stickydisk-gc.yml

Security Review

The manual retention input reaches an organization-token-backed deletion loop without a positive lower-bound check. A zero or negative value can select recently used or all matching shared node_modules disks for deletion. How this was verified: The workflow passes the unconstrained input through --argjson into a future-or-current cutoff and then deletes every selected key without another age guard.

Important Files Changed

Filename Overview
.github/workflows/stickydisk-gc.yml Adds the complete sticky-disk collection workflow, but lacks a safe retention bound, omits fork-key variants, and processes only one result page.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Schedule[Daily schedule] --> Input[Retention defaults to 3 days]
  Dispatch[Manual dispatch] --> Input
  Input --> List[List matching sticky disks]
  List --> Guard[Validate key shape]
  Guard --> Group[Group architecture variants]
  Group --> Cutoff[Compare newest use with cutoff]
  Cutoff --> DryRun{Dry run?}
  DryRun -->|Yes| Report[Report candidates]
  DryRun -->|No| Delete[Delete each matching key]
  Delete --> Report
Loading

Reviews (1): Last reviewed commit: "improvement(ci): collect idle node_modul..." | Re-trigger Greptile

Comment on lines +108 to +109
jq -r --arg repo "$TARGET_REPO" --argjson days "$RETENTION_DAYS" '
(now - ($days * 86400)) as $cutoff

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Destructive retention cutoff

When a manual run supplies zero or a negative retention_days, the cutoff selects recently used or every matching shared disk, causing active CI caches to be deleted. Reject non-positive values before calculating the cutoff. How this was verified: The unconstrained dispatch input is parsed by --argjson, used directly in the cutoff, and every resulting key reaches the authenticated delete command.

Suggested change
jq -r --arg repo "$TARGET_REPO" --argjson days "$RETENTION_DAYS" '
(now - ($days * 86400)) as $cutoff
jq -e -n --argjson days "$RETENTION_DAYS" '$days > 0' > /dev/null
jq -r --arg repo "$TARGET_REPO" --argjson days "$RETENTION_DAYS" '
(now - ($days * 86400)) as $cutoff

(now - ($days * 86400)) as $cutoff
| .entries
| map(select(.type == "stickydisk"))
| map(select(.key | test("^" + ($repo | gsub("/"; "\\/")) + "-node-modules-[a-z_]+-[0-9a-f]{64}$")))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Fork keys bypass collection

For fork pull requests, the existing key producer emits pull_request-fork, but [a-z_]+ rejects the hyphenated suffix. Those node_modules disks are never selected by this collector and therefore retain the vendor's seven-day inactivity window instead of the configured three days.

Suggested change
| map(select(.key | test("^" + ($repo | gsub("/"; "\\/")) + "-node-modules-[a-z_]+-[0-9a-f]{64}$")))
| map(select(.key | test("^" + ($repo | gsub("/"; "\\/")) + "-node-modules-[a-z_]+(-fork)?-[0-9a-f]{64}$")))

Comment on lines +88 to +92
--repo "$TARGET_REPO" \
--search '-node-modules-' \
--per-page 100 \
--format json > disks.json

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Single-page disk listing

If the node_modules disk family exceeds 100 entries after an interruption in collection, this single list request processes only the first page. Entries outside that page remain billed until Blacksmith's longer vendor eviction window, so the workflow should iterate through all pages or fail explicitly when results are truncated.

@waleedlatif1
waleedlatif1 deleted the ci/gc-idle-node-modules-sticky-disks branch August 28, 2026 17:27
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