Skip to content

Commit 3323193

Browse files
committed
improvement(ci): collect idle node_modules sticky disks at 3 days
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.
1 parent 9e79dd2 commit 3323193

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
name: Sticky Disk GC
2+
3+
# node_modules sticky disks are keyed on hashFiles('bun.lock') by design — see the
4+
# "Mount node_modules" comment in test-build.yml. A sticky disk is a mutable volume
5+
# and `bun install --frozen-lockfile` adds what the lockfile needs without pruning
6+
# what it dropped, so branches on different lockfiles must not share one. That
7+
# design is correct and is preserved here; its cost is a new 6-16 GB disk per
8+
# lockfile bump, created at ~4.7/day.
9+
#
10+
# Blacksmith already evicts any sticky disk after 7 days of inactivity, so this is
11+
# not a leak — it is a retention window that is far too generous for this key. The
12+
# median disk is only USED for ~0.16 days and then billed for another 7, so the
13+
# retention tail is almost the entire cost. This collects at 3 days instead.
14+
#
15+
# Age-based on purpose. The key contains NO PR identifier, so every open PR whose
16+
# checkout has the same bun.lock mounts the SAME disk — with ~180 open PRs sharing
17+
# on the order of 20 distinct lockfile hashes, the shared case is the common case.
18+
# Deleting on PR close would therefore destroy a disk that dozens of other open PRs
19+
# are actively using. Never add a pull_request or pull_request_target trigger here.
20+
21+
on:
22+
schedule:
23+
- cron: '17 9 * * *'
24+
workflow_dispatch:
25+
inputs:
26+
retention_days:
27+
description: Delete node_modules disks unused for more than this many days.
28+
required: false
29+
default: '3'
30+
dry_run:
31+
description: List what would be deleted without deleting it.
32+
type: boolean
33+
required: false
34+
default: false
35+
36+
# Nothing in this job reads the repository.
37+
permissions: {}
38+
39+
concurrency:
40+
group: stickydisk-gc
41+
cancel-in-progress: false
42+
43+
jobs:
44+
gc:
45+
name: Reclaim idle node_modules sticky disks
46+
# GitHub-hosted on purpose, not a Blacksmith runner: the CLI is a pure API
47+
# client, and GC has to keep working during a Blacksmith outage or a
48+
# CI_PROVIDER break-glass switch — exactly when disks sit idle and still bill.
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 10
51+
env:
52+
# Pinned binary + checksum rather than `curl https://get.blacksmith.sh | sh`:
53+
# this job holds an org-wide token, so it must not execute unpinned remote
54+
# shell. Bump both values together; the vendor publishes a .sha256 sidecar
55+
# next to the binary to check against.
56+
BLACKSMITH_CLI_VERSION: v0.4.57
57+
BLACKSMITH_CLI_SHA256: 7f60f3b9f8d4d7644d9743f5d962acb3b3dbf675f51676702e5f292e02060bca
58+
# The CLI self-updates in the background on every invocation, which would
59+
# silently defeat the pin above.
60+
BLACKSMITH_DISABLE_AUTO_UPDATE: '1'
61+
BLACKSMITH_ORG: simstudioai
62+
TARGET_REPO: ${{ github.repository }}
63+
RETENTION_DAYS: ${{ inputs.retention_days || '3' }}
64+
DRY_RUN: ${{ inputs.dry_run || 'false' }}
65+
66+
steps:
67+
- name: Install Blacksmith CLI
68+
run: |
69+
set -euo pipefail
70+
url="https://clireleases.blacksmith.sh/cli/${BLACKSMITH_CLI_VERSION}/linux/amd64/blacksmith"
71+
curl -fsSL "$url" -o /usr/local/bin/blacksmith
72+
echo "${BLACKSMITH_CLI_SHA256} /usr/local/bin/blacksmith" | sha256sum -c -
73+
chmod +x /usr/local/bin/blacksmith
74+
75+
- name: Authenticate
76+
env:
77+
BLACKSMITH_CLI_TOKEN: ${{ secrets.BLACKSMITH_CLI_TOKEN }}
78+
run: |
79+
set -euo pipefail
80+
printf '%s' "$BLACKSMITH_CLI_TOKEN" \
81+
| blacksmith auth login --api-token - --non-interactive --organization "$BLACKSMITH_ORG"
82+
83+
- name: Delete node_modules disks idle beyond the retention window
84+
run: |
85+
set -euo pipefail
86+
87+
blacksmith stickydisk list \
88+
--repo "$TARGET_REPO" \
89+
--search '-node-modules-' \
90+
--per-page 100 \
91+
--format json > disks.json
92+
93+
# Two independent guards, because the blast radius of a wrong key is a
94+
# cache every CI job depends on:
95+
# 1. --search narrows server-side to the node_modules family.
96+
# 2. The regex re-proves each key's full shape locally. The event
97+
# segment is [a-z_]+ rather than an enumerated list — the key
98+
# interpolates ${{ github.event_name }}, and a workflow_dispatch
99+
# disk already exists that an enumerated push|pull_request would
100+
# have silently skipped forever.
101+
# Verified against the live account: this matches all 39 node_modules
102+
# disks and none of the 19 bun/turbo/Docker disks, which are mounted
103+
# every run, never idle, and must survive.
104+
#
105+
# Grouped by key before the staleness test because `delete` without
106+
# --arch removes every architecture variant, so a key may only go when
107+
# its NEWEST variant is stale.
108+
jq -r --arg repo "$TARGET_REPO" --argjson days "$RETENTION_DAYS" '
109+
(now - ($days * 86400)) as $cutoff
110+
| .entries
111+
| map(select(.type == "stickydisk"))
112+
| map(select(.key | test("^" + ($repo | gsub("/"; "\\/")) + "-node-modules-[a-z_]+-[0-9a-f]{64}$")))
113+
| group_by(.key)
114+
| map({
115+
key: .[0].key,
116+
gb: (map(.size_bytes) | add / 1000000000 * 100 | round / 100),
117+
last_used: (map(.last_used_at | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) | max)
118+
})
119+
| map(select(.last_used < $cutoff))
120+
| .[] | "\(.key)\t\(.gb)"
121+
' disks.json > stale.tsv
122+
123+
count=$(wc -l < stale.tsv | tr -d ' ')
124+
reclaimed=$(awk -F'\t' '{s+=$2} END {printf "%.1f", s+0}' stale.tsv)
125+
{
126+
echo "### Sticky disk GC"
127+
echo "Retention: ${RETENTION_DAYS}d · dry run: ${DRY_RUN} · candidates: ${count} (${reclaimed} GB)"
128+
} >> "$GITHUB_STEP_SUMMARY"
129+
130+
failed=0
131+
while IFS=$'\t' read -r key gb; do
132+
[ -n "$key" ] || continue
133+
if [ "$DRY_RUN" = "true" ]; then
134+
echo "- would delete \`${key}\` (${gb} GB)" >> "$GITHUB_STEP_SUMMARY"
135+
continue
136+
fi
137+
if blacksmith stickydisk delete --repo "$TARGET_REPO" --key "$key" --yes; then
138+
echo "- deleted \`${key}\` (${gb} GB)" >> "$GITHUB_STEP_SUMMARY"
139+
else
140+
echo "- FAILED \`${key}\`" >> "$GITHUB_STEP_SUMMARY"
141+
failed=1
142+
fi
143+
done < stale.tsv
144+
145+
# Fail loudly rather than continue-on-error: a revoked token or a changed
146+
# CLI JSON shape would otherwise silently revert us to 7-day billing.
147+
exit "$failed"

0 commit comments

Comments
 (0)