mandrel-platform 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,16 +3,23 @@
3
3
  * check-docs-staleness.test.mjs — node:test suite for the docs-staleness lint
4
4
  * (Story #197).
5
5
  *
6
- * Focus: the `expired-placeholder` rule. The rule previously hardcoded the
7
- * years 2020–2024 (`/expires[:\s]+202[0-4]-\d{2}-\d{2}/i`), so an expiry that
8
- * lapsed in 2025, 2026, or any later year sailed through the gate — a
9
- * fail-open. The fix broadens the pattern to any 20xx year and defers the
10
- * "is it actually in the past?" decision to `isExpiredDate`, so the rule stays
11
- * correct as the calendar advances and never flags a still-valid future date.
6
+ * Focus: rule PRECISION — the two ways a staleness rule stops being useful.
12
7
  *
13
- * These tests exercise the year fix directly (`isExpiredDate`) and end-to-end
14
- * (`lintFile` against a real fixture file), pinning "today" via a fixed clock
15
- * so they are deterministic.
8
+ * 1. Fail-open (`expired-placeholder` under-fires). The rule first hardcoded
9
+ * the years 2020–2024 (`/expires[:\s]+202[0-4]-\d{2}-\d{2}/i`), so an expiry
10
+ * that lapsed in 2025 or later sailed through. The fix broadens to any 20xx
11
+ * year and defers "is it actually in the past?" to `isExpiredDate`, so the
12
+ * rule stays correct as the calendar advances and never flags a still-valid
13
+ * future date. A second instance of the same class: the separator required
14
+ * `expires` to be followed directly by `:` or whitespace, which skipped the
15
+ * CVE allowlist's own JSON shape (`"expires": "…"`).
16
+ * 2. Fail-noisy (`quality-yml-ref` over-fires). A plain substring match meant
17
+ * the platform's own `pr-quality.yml` matched, so 58 of 59 findings were
18
+ * false positives — enough to bury a real error in the same run.
19
+ *
20
+ * These tests exercise the date logic directly (`isExpiredDate`), the compiled
21
+ * patterns, and end-to-end behaviour (`lintFile` against a real fixture file),
22
+ * pinning "today" via a fixed clock so they are deterministic.
16
23
  *
17
24
  * Run: node --test scripts/check-docs-staleness.test.mjs
18
25
  */
@@ -128,3 +135,101 @@ test('lintFile honours the staleness-ignore suppression comment for the year rul
128
135
  },
129
136
  );
130
137
  });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // expired-placeholder — the JSON-quoted shape.
141
+ //
142
+ // Second fail-open of the same class as the 202[0-4] year window: the pattern
143
+ // required `expires` to be followed directly by `:` or whitespace, so the CVE
144
+ // allowlist's own JSON shape (`"expires": "2026-12-31"`, per audit-check.mjs)
145
+ // never matched — `expires"` is neither. Every documented allowlist entry was
146
+ // therefore invisible to the gate, including a lapsed one in
147
+ // docs/runbooks/dependency-update.md.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test('expired-placeholder matches the JSON-quoted allowlist shape', () => {
151
+ const rule = RULES.find((r) => r.id === 'expired-placeholder');
152
+ for (const line of [
153
+ ' "expires": "2025-12-31",', // the CVE allowlist's real shape
154
+ " 'expires': '2025-12-31',", // single-quoted (YAML/JS)
155
+ ' expires: "2025-12-31"', // quoted value, bare key
156
+ ]) {
157
+ rule.pattern.lastIndex = 0;
158
+ assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
159
+ }
160
+ });
161
+
162
+ test('expired-placeholder still matches the unquoted shapes (no regression)', () => {
163
+ const rule = RULES.find((r) => r.id === 'expired-placeholder');
164
+ for (const line of [
165
+ 'expires: 2025-01-01',
166
+ '# CVE-2022-3517 — expires 2025-06-01',
167
+ ]) {
168
+ rule.pattern.lastIndex = 0;
169
+ assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
170
+ }
171
+ });
172
+
173
+ test('lintFile flags a lapsed JSON-quoted expiry end-to-end', () => {
174
+ withTempDoc('Allowlist entry.\n "expires": "2025-12-31",\nEnd.\n', (file) => {
175
+ const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
176
+ assert.equal(expired.length, 1);
177
+ assert.match(expired[0].match, /2025-12-31/);
178
+ });
179
+ });
180
+
181
+ test('lintFile does NOT flag a future JSON-quoted expiry', () => {
182
+ withTempDoc('Allowlist entry.\n "expires": "2099-12-31",\nEnd.\n', (file) => {
183
+ const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
184
+ assert.equal(expired.length, 0);
185
+ });
186
+ });
187
+
188
+ test('lintFile does NOT flag a placeholder expiry token', () => {
189
+ // The runbooks intentionally use `<YYYY-MM-DD>` rather than a concrete date,
190
+ // precisely so an example can never lapse into a finding.
191
+ withTempDoc('Allowlist entry.\n "expires": "<YYYY-MM-DD>",\nEnd.\n', (file) => {
192
+ const expired = lintFile(file).filter((f) => f.rule.id === 'expired-placeholder');
193
+ assert.equal(expired.length, 0);
194
+ });
195
+ });
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // quality-yml-ref — bare filename only.
199
+ //
200
+ // The pattern was a plain substring match, so the platform's own
201
+ // `pr-quality.yml` matched: 58 of 59 findings against mandrel-platform's docs
202
+ // were that false positive, burying a real `expired-placeholder` error. The
203
+ // assertion below is on the invariant (a `<prefix>-quality.yml` is a different
204
+ // file) rather than on the single `pr-` spelling that motivated it.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ test('quality-yml-ref does NOT match a prefixed <prefix>-quality.yml', () => {
208
+ const rule = RULES.find((r) => r.id === 'quality-yml-ref');
209
+ assert.ok(rule, 'quality-yml-ref rule must exist');
210
+ for (const line of [
211
+ 'uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@abc123',
212
+ 'the `pr-quality.yml` reusable workflow',
213
+ 'a consumer that names its caller `ci-quality.yml`',
214
+ 'see my_quality.yml for details',
215
+ ]) {
216
+ rule.pattern.lastIndex = 0;
217
+ assert.equal(
218
+ rule.pattern.test(line),
219
+ false,
220
+ `pattern must not match ${JSON.stringify(line)}`,
221
+ );
222
+ }
223
+ });
224
+
225
+ test('quality-yml-ref still matches a bare quality.yml reference', () => {
226
+ const rule = RULES.find((r) => r.id === 'quality-yml-ref');
227
+ for (const line of [
228
+ "| athportal | `quality.yml` | `quality` |",
229
+ 'the quality.yml workflow was renamed',
230
+ 'quality.yml',
231
+ ]) {
232
+ rule.pattern.lastIndex = 0;
233
+ assert.ok(rule.pattern.test(line), `pattern should match ${JSON.stringify(line)}`);
234
+ }
235
+ });
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * job-cleanup-hook.test.mjs — node:test suite for the ACTIONS_RUNNER_HOOK_JOB_STARTED
4
+ * hook shipped at `templates/runner/job-cleanup.sh` (Story #345).
5
+ *
6
+ * The hook runs INSIDE the job's clock on every job of every persistent
7
+ * self-hosted runner, so its cost is charged to `Set up runner` and a slow
8
+ * hook kills jobs against their own `timeout-minutes`. Issue #343 is exactly
9
+ * that failure: the hook enumerated the shared OS temp root, which had reached
10
+ * 841,690 entries on the swarm-os host, and `Set up runner` reached 5m29s.
11
+ *
12
+ * The load-bearing property this suite pins is therefore a NEGATIVE one — the
13
+ * hook must never read the shared temp root — and it is asserted two ways,
14
+ * because neither alone is sufficient:
15
+ *
16
+ * 1. Behaviourally: a decoy shared temp root is planted with entries that
17
+ * match the old sweep's patterns, and the hook must leave every one of
18
+ * them alone. This catches a sweep that still deletes there.
19
+ * 2. Structurally: the script text must contain no enumeration rooted at the
20
+ * shared temp root. This catches a sweep that reads the directory but
21
+ * happens to delete nothing — the exact shape of the #343 stall, which
22
+ * was pure cost with no observable effect.
23
+ *
24
+ * A wall-clock assertion was deliberately NOT used for (2): the cost is a
25
+ * function of host churn, so on a clean dev machine a full enumeration of a
26
+ * small decoy root is fast and would pass. Timing here would be a test that
27
+ * only fails on the machine that least needs it.
28
+ *
29
+ * The suite executes the real script with env fixtures, following the
30
+ * `scripts/resolve-diff-range.test.mjs` precedent for shell-under-test.
31
+ *
32
+ * Run: node --test scripts/job-cleanup-hook.test.mjs
33
+ */
34
+
35
+ import assert from "node:assert/strict";
36
+ import { execFileSync } from "node:child_process";
37
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, chmodSync, existsSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
39
+ import { fileURLToPath } from "node:url";
40
+ import { dirname, join } from "node:path";
41
+ import { test, after } from "node:test";
42
+
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ const SCRIPT = join(HERE, "..", "templates", "runner", "job-cleanup.sh");
45
+
46
+ /** Sandboxes created by the suite, torn down in `after`. */
47
+ const SANDBOXES = [];
48
+
49
+ after(() => {
50
+ for (const dir of SANDBOXES) {
51
+ // Restore any permissions the unwritable-temp case removed, or the
52
+ // recursive delete cannot descend.
53
+ try {
54
+ chmodSync(join(dir, "runner", "_work", "_temp"), 0o755);
55
+ } catch {
56
+ /* not every sandbox has one */
57
+ }
58
+ rmSync(dir, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ /**
63
+ * Build a sandbox holding a synthetic runner root and a decoy shared temp root.
64
+ *
65
+ * @param {{ sharedEntries?: number }} [opts]
66
+ * @returns {{ root: string, runnerDir: string, runnerTmp: string, sharedTmp: string }}
67
+ */
68
+ function makeSandbox({ sharedEntries = 0 } = {}) {
69
+ const root = mkdtempSync(join(tmpdir(), "job-cleanup-test-"));
70
+ SANDBOXES.push(root);
71
+
72
+ const runnerDir = join(root, "runner");
73
+ const runnerTmp = join(runnerDir, "_work", "_temp");
74
+ const sharedTmp = join(root, "shared-tmp");
75
+ mkdirSync(runnerTmp, { recursive: true });
76
+ mkdirSync(join(runnerDir, "_work", "_tool"), { recursive: true });
77
+ mkdirSync(sharedTmp, { recursive: true });
78
+
79
+ // The decoy shared root carries entries matching BOTH the retired sweep's
80
+ // patterns and the current runner-scoped ones, so a sweep that kept the old
81
+ // root or reused the new globs against it is caught either way.
82
+ writeFileSync(join(sharedTmp, "gitleaks.tmp"), "decoy");
83
+ mkdirSync(join(sharedTmp, "gitleaks-8.30.1"), { recursive: true });
84
+ mkdirSync(join(sharedTmp, "gitleaks.AbCdEf"), { recursive: true });
85
+ mkdirSync(join(sharedTmp, "osv-scanner.AbCdEf"), { recursive: true });
86
+ for (let i = 0; i < sharedEntries; i += 1) {
87
+ writeFileSync(join(sharedTmp, `unrelated-${i}.tmp`), "x");
88
+ }
89
+
90
+ return { root, runnerDir, runnerTmp, sharedTmp };
91
+ }
92
+
93
+ /**
94
+ * Run the hook against a sandbox.
95
+ *
96
+ * @param {{ runnerDir: string, sharedTmp: string }} sandbox
97
+ * @returns {{ status: number, stdout: string }}
98
+ */
99
+ function runHook({ runnerDir, sharedTmp }) {
100
+ try {
101
+ const stdout = execFileSync("bash", [SCRIPT], {
102
+ encoding: "utf8",
103
+ env: { ...process.env, RUNNER_DIR: runnerDir, TMPDIR: sharedTmp },
104
+ timeout: 60_000,
105
+ });
106
+ return { status: 0, stdout };
107
+ } catch (err) {
108
+ return { status: err.status ?? 1, stdout: err.stdout ?? "" };
109
+ }
110
+ }
111
+
112
+ test("removes this runner's own stale tool-download and pnpm-shim leftovers", () => {
113
+ const sandbox = makeSandbox();
114
+ const { runnerTmp } = sandbox;
115
+
116
+ // Every artifact the platform's actions now extract into runner.temp.
117
+ const owned = [
118
+ join(runnerTmp, "pnpm"),
119
+ join(runnerTmp, "setup-pnpm"),
120
+ join(runnerTmp, "gitleaks.AbCdEf"),
121
+ join(runnerTmp, "osv-scanner.AbCdEf"),
122
+ join(runnerTmp, "semgrep.AbCdEf"),
123
+ ];
124
+ for (const dir of owned) {
125
+ mkdirSync(dir, { recursive: true });
126
+ writeFileSync(join(dir, "leftover"), "stale");
127
+ }
128
+ writeFileSync(join(runnerTmp, "gh-api-err.AbCdEf"), "stale");
129
+
130
+ const { status } = runHook(sandbox);
131
+ assert.equal(status, 0, "the hook must never fail a job");
132
+
133
+ for (const dir of owned) {
134
+ assert.equal(existsSync(dir), false, `runner-owned leftover not swept: ${dir}`);
135
+ }
136
+ assert.equal(existsSync(join(runnerTmp, "gh-api-err.AbCdEf")), false);
137
+ });
138
+
139
+ test("leaves the shared temp root untouched", () => {
140
+ // The decoy is deliberately SMALL. An earlier draft planted 5,000 entries to
141
+ // evoke the 841,690 seen on the swarm-os host, but that bought no signal: the
142
+ // assertion below is on effect (nothing deleted), which is count-independent,
143
+ // and the companion structural test — not a stopwatch — is what pins the
144
+ // cost property. Planting alone cost ~87s on a dev Mac whose endpoint
145
+ // security scans every write (~17ms/file), which would have made this the
146
+ // slowest test in the suite by two orders of magnitude, and a flaky one.
147
+ //
148
+ // What actually matters is pattern COVERAGE, and makeSandbox plants every
149
+ // shape — both the retired sweep's names and the current runner-scoped
150
+ // globs — so a sweep pointed back at the shared root is caught by the first
151
+ // entry it would match.
152
+ const sandbox = makeSandbox({ sharedEntries: 20 });
153
+ const { sharedTmp } = sandbox;
154
+
155
+ const before = readdirSync(sharedTmp).sort();
156
+ const { status } = runHook(sandbox);
157
+
158
+ assert.equal(status, 0);
159
+ assert.deepEqual(
160
+ readdirSync(sharedTmp).sort(),
161
+ before,
162
+ "the hook deleted from the shared temp root — it must only sweep runner-owned paths",
163
+ );
164
+ });
165
+
166
+ test("the script text contains no enumeration rooted at the shared temp root", () => {
167
+ const source = readFileSync(SCRIPT, "utf8");
168
+
169
+ // Assert the PROPERTY (the hook cannot reach the shared root at all), not
170
+ // one syntactic form of violating it. Pinning a specific `find "${TMP…"`
171
+ // shape would miss an unbraced `$TMPDIR`, an `ls | grep`, a `for f in
172
+ // "${TMP}"/…` loop, or a renamed intermediate variable — all of which
173
+ // reintroduce the unbounded read this Story removed.
174
+ //
175
+ // Every one of those must name the shared root to reach it, and the hook has
176
+ // no legitimate use for it: RUNNER_TMP derives from RUNNER_DIR. So the
177
+ // absence of the name is both necessary and sufficient, and it stays a true
178
+ // invariant rather than a regex chasing syntax.
179
+ //
180
+ // Comments are stripped first: the header documents the #343 incident by
181
+ // name, and that prose is the reason the next maintainer will not re-add the
182
+ // sweep. Asserting over raw text would force the fix to delete its own
183
+ // rationale.
184
+ const code = source
185
+ .split("\n")
186
+ .filter((line) => !/^\s*#/.test(line))
187
+ .join("\n");
188
+
189
+ assert.equal(
190
+ /TMPDIR/.test(code),
191
+ false,
192
+ "the hook references the shared temp root — its cost must scale with runner-owned state only",
193
+ );
194
+
195
+ // Second half of the property: no directory enumeration, anywhere. Naming
196
+ // the shared root is one way to reintroduce unbounded cost; a `find` rooted
197
+ // at an intermediate variable is another, and it would not have to mention
198
+ // TMPDIR on the same line. The hook has no legitimate need to enumerate —
199
+ // it addresses known paths under RUNNER_TMP directly — so the absence of
200
+ // `find` is a stronger and more durable invariant than any pattern match
201
+ // over its arguments.
202
+ assert.equal(
203
+ /\bfind\b/.test(code),
204
+ false,
205
+ "the hook enumerates a directory — address known runner-owned paths directly instead",
206
+ );
207
+
208
+ // The age gate existed solely to make deleting from the SHARED root safe.
209
+ // Runner-scoped paths are unreachable by a co-resident runner, so a
210
+ // surviving knob would be dead configuration the runbook still promises.
211
+ assert.equal(
212
+ source.includes("JOB_CLEANUP_STALE_MINUTES"),
213
+ false,
214
+ "the retired age-gate knob is still referenced",
215
+ );
216
+ });
217
+
218
+ test("exits 0 when the runner directory does not exist", () => {
219
+ const sandbox = makeSandbox();
220
+ const { status } = runHook({
221
+ runnerDir: join(sandbox.root, "no-such-runner"),
222
+ sharedTmp: sandbox.sharedTmp,
223
+ });
224
+ assert.equal(status, 0, "a missing runner root must degrade to a no-op, never fail the job");
225
+ });
226
+
227
+ test("exits 0 when the runner temp is unwritable", () => {
228
+ const sandbox = makeSandbox();
229
+ mkdirSync(join(sandbox.runnerTmp, "gitleaks.AbCdEf"), { recursive: true });
230
+ chmodSync(sandbox.runnerTmp, 0o500);
231
+
232
+ const { status } = runHook(sandbox);
233
+ assert.equal(status, 0, "an unwritable runner temp must not fail the job");
234
+ });
@@ -150,9 +150,18 @@ with the runner root's absolute path. The resulting file wires:
150
150
 
151
151
  - `ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh` — the
152
152
  job-start hook. It reaps orphaned pnpm/node processes parented to **this**
153
- runner's work tree, clears stale runner-scoped pnpm installs, and
154
- age-gate-sweeps shared-`$TMPDIR` gitleaks leftovers. It never fails a job
155
- (always exits 0) and never touches another runner's state.
153
+ runner's work tree, clears stale runner-scoped pnpm installs, and removes
154
+ leftover tool-download dirs from `<RUNNER_DIR>/_work/_temp`. It never fails
155
+ a job (always exits 0) and never touches another runner's state.
156
+
157
+ **Every path it reads is runner-scoped, and that is load-bearing** (issue
158
+ #343). The hook runs inside the *job's* clock, so its cost is charged to
159
+ `Set up runner` and counts against the job's own `timeout-minutes`. An
160
+ earlier version swept the host-shared OS temp root; on a host where that
161
+ directory had grown to ~840k entries, `Set up runner` reached 5m29s and
162
+ jobs were killed before their first real step — surfacing as `cancelled`
163
+ on unrelated diffs. If you add a sweep to this hook, root it at
164
+ `_work/_temp`, never at `$TMPDIR`.
156
165
  - `RUNNER_TOOL_CACHE=<RUNNER_DIR>/_work/_tool` and
157
166
  `AGENT_TOOLSDIRECTORY=<RUNNER_DIR>/_work/_tool` — runner-scoped tool cache
158
167
  (two env names, one dir; some actions read the legacy name).
@@ -19,8 +19,14 @@ LANG=en_US.UTF-8
19
19
 
20
20
  # Job-start hygiene hook. Runs templates/runner/job-cleanup.sh (installed
21
21
  # into the runner root) before every job: reaps orphaned pnpm/node processes
22
- # from THIS runner's work tree and clears stale install/temp artifacts.
23
- # The hook is runner-scoped and never fails the job (always exits 0).
22
+ # from THIS runner's work tree and clears stale install/temp artifacts from
23
+ # `_work/_temp`. The hook is runner-scoped and never fails the job (always
24
+ # exits 0).
25
+ #
26
+ # Runner-scoped means CHEAP as well as safe: the hook runs inside the job's
27
+ # clock, so anything it reads is billed to `Set up runner` and counts against
28
+ # the job's `timeout-minutes`. It touches only this runner's own paths, so its
29
+ # cost never becomes a function of host-wide temp churn (issue #343).
24
30
  ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh
25
31
 
26
32
  # Runner-scoped tool cache. Without this, actions/setup-node & friends
@@ -10,8 +10,8 @@
10
10
  #
11
11
  # - an orphaned `pnpm`/`node` process (e.g. a hung install or lint) still
12
12
  # mutating the pnpm shim install, corrupting the pnpm CLI for the next job;
13
- # - leftover `gitleaks.tmp` / `gitleaks-*` artifacts in the shared $TMPDIR
14
- # blocking the next gitleaks download.
13
+ # - leftover tool-download temp dirs (gitleaks, OSV-scanner, the semgrep
14
+ # venv) accumulating in the runner's own job temp.
15
15
  #
16
16
  # Running this before every job gives each job a clean slate ("fresh per job"
17
17
  # without the cost of re-registering an ephemeral runner).
@@ -35,9 +35,36 @@
35
35
  # own work tree (`<RUNNER_DIR>/_work/...`). Every path below derives
36
36
  # from RUNNER_DIR, which is unique per runner, so a co-resident
37
37
  # runner's processes and files are never matched.
38
- # 3. Age-gates cleanup of the genuinely shared $TMPDIR gitleaks artifacts,
39
- # so a fresh (in-flight) download owned by a concurrent job is never
40
- # deleted only stale leftovers are.
38
+ # 3. NEVER READS the shared OS temp root, either. Reading is not free: the
39
+ # hook runs inside the JOB's clock, so any cost here is charged to
40
+ # `Set up runner` and counts against the job's own `timeout-minutes`.
41
+ # $TMPDIR is unbounded and shared with every other process on the host,
42
+ # so a sweep rooted there costs a function of how much UNRELATED junk
43
+ # the host has accumulated — see the incident note below.
44
+ #
45
+ # ── WHY THE SHARED-$TMPDIR SWEEP IS GONE (issue #343) ───────────────────────
46
+ #
47
+ # This hook used to age-gate two `find "$TMPDIR" -maxdepth 1 -name …` sweeps
48
+ # for `gitleaks.tmp` / `gitleaks-*`. `-maxdepth 1 -name <literal>` is a FULL
49
+ # directory enumeration for what is really an existence check, so its cost
50
+ # scaled with host churn. On the swarm-os runner host $TMPDIR reached 841,690
51
+ # entries; one scan measured 42s, the hook ran two of them, and up to 16
52
+ # co-resident runners ran it concurrently. `Set up runner` reached 5m29s, and
53
+ # every job whose `timeout-minutes` sat at or below that was killed before its
54
+ # first real step — surfacing as `cancelled` on an innocent diff.
55
+ #
56
+ # It was also a no-op: the platform's actions extract via `mktemp -d`, so
57
+ # nothing ever created `gitleaks.tmp` or `gitleaks-*`. The sweep paid an
58
+ # unbounded cost hunting names that never existed, while the dirs the actions
59
+ # DID leave went unswept.
60
+ #
61
+ # The fix is ownership, not tuning: every platform action now extracts into
62
+ # `${RUNNER_TEMP}` (== RUNNER_TMP below), which is unique per runner. A
63
+ # co-resident runner's in-flight download is therefore unreachable from here
64
+ # by construction — which is what retired the age gate outright (along with
65
+ # the stale-minutes env knob that tuned it), rather than merely shrinking its
66
+ # blast radius. Keep it that way: a sweep added here MUST be rooted at
67
+ # RUNNER_TMP.
41
68
  #
42
69
  # ── PARAMETERIZATION ────────────────────────────────────────────────────────
43
70
  #
@@ -48,11 +75,8 @@
48
75
  # runner root, next to config.sh / run.sh). Override via env
49
76
  # only if you install the hook elsewhere.
50
77
  # RUNNER_TMP — the runner's per-runner job temp (`runner.temp`), always
51
- # `${RUNNER_DIR}/_work/_temp`.
52
- # JOB_CLEANUP_STALE_MINUTES
53
- # — age threshold (minutes) for the shared-$TMPDIR gitleaks
54
- # sweep. Default 60. Artifacts younger than this are assumed
55
- # in-flight and left alone.
78
+ # `${RUNNER_DIR}/_work/_temp`. Every path this hook touches
79
+ # lives under it.
56
80
  #
57
81
  # Configured via `ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh`
58
82
  # in the runner's `.env` (see .env.example in this directory).
@@ -64,8 +88,6 @@ set +e
64
88
  RUNNER_DIR="${RUNNER_DIR:-$(cd "$(dirname "$0")" && pwd)}"
65
89
  RUNNER_WORK="${RUNNER_DIR}/_work"
66
90
  RUNNER_TMP="${RUNNER_WORK}/_temp"
67
- TMP="${TMPDIR:-/tmp}"
68
- STALE_MINUTES="${JOB_CLEANUP_STALE_MINUTES:-60}"
69
91
 
70
92
  # 1) Reap orphaned pnpm/node processes from prior jobs — scoped to THIS
71
93
  # runner's work tree only. The patterns target executable paths INSIDE the
@@ -85,13 +107,20 @@ pkill -9 -f "${RUNNER_WORK}/_tool/[^ ]*node_modules" 2>/dev/null
85
107
  rm -rf "${RUNNER_TMP}/pnpm" 2>/dev/null
86
108
  rm -rf "${RUNNER_TMP}/setup-pnpm" 2>/dev/null
87
109
 
88
- # 3) Sweep stale gitleaks artifacts from the SHARED $TMPDIR. Because this
89
- # location is shared by every runner on the host, deletion is age-gated:
90
- # only artifacts older than STALE_MINUTES are removed, so a concurrent
91
- # runner's in-flight download is never deleted mid-job.
92
- find "${TMP}" -maxdepth 1 -name 'gitleaks.tmp' -mmin "+${STALE_MINUTES}" \
93
- -exec rm -f {} + 2>/dev/null
94
- find "${TMP}" -maxdepth 1 -name 'gitleaks-*' -mmin "+${STALE_MINUTES}" \
95
- -exec rm -rf {} + 2>/dev/null
110
+ # 3) Remove this runner's own leftover tool-download temp dirs. The platform's
111
+ # composite actions and workflows create these via
112
+ # `mktemp -d "${RUNNER_TEMP}/<tool>.XXXXXX"`, so every one of them is
113
+ # runner-scoped and a co-resident runner's in-flight download is
114
+ # unreachable here no age gate is needed (see the issue #343 note above).
115
+ #
116
+ # Globbing is what keeps this bounded: the shell expands these against
117
+ # RUNNER_TMP alone, so the cost is a function of THIS runner's leftovers,
118
+ # never of host-wide churn. Do not replace it with a `find` over a parent.
119
+ # A glob that matches nothing stays literal, and `rm -rf` on a nonexistent
120
+ # path is silent — hence the nullglob-free form plus 2>/dev/null.
121
+ rm -rf "${RUNNER_TMP}"/gitleaks.* 2>/dev/null
122
+ rm -rf "${RUNNER_TMP}"/osv-scanner.* 2>/dev/null
123
+ rm -rf "${RUNNER_TMP}"/semgrep.* 2>/dev/null
124
+ rm -f "${RUNNER_TMP}"/gh-api-err.* 2>/dev/null
96
125
 
97
126
  exit 0