mandrel-platform 1.0.0 → 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.
- package/package.json +1 -1
- package/scripts/audit-check.mjs +112 -12
- package/scripts/audit-check.test.mjs +195 -0
- package/scripts/check-cancelled-provenance.test.mjs +986 -0
- package/scripts/check-ci-required-aggregator.test.mjs +169 -13
- package/scripts/check-destructive-migration.test.mjs +37 -0
- package/scripts/check-docs-staleness.mjs +15 -2
- package/scripts/check-docs-staleness.test.mjs +114 -9
- package/scripts/check-fail-fast-attribution.test.mjs +390 -0
- package/scripts/job-cleanup-hook.test.mjs +234 -0
- package/scripts/platform-sync.mjs +89 -15
- package/scripts/platform-sync.test.mjs +125 -0
- package/templates/runbooks/runner-provisioning.md +12 -3
- package/templates/runner/.env.example +8 -2
- package/templates/runner/job-cleanup.sh +49 -20
|
@@ -197,6 +197,52 @@ function log(msg) {
|
|
|
197
197
|
if (!opts.json) process.stdout.write(`${msg}\n`);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Filesystem access — perform, don't pre-check (Story #337)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
//
|
|
204
|
+
// Every read in this script used to be guarded by `existsSync(p)` before
|
|
205
|
+
// `readFileSync(p)` / `readdirSync(p)`. That check-then-use shape is a
|
|
206
|
+
// time-of-check/time-of-use race (CodeQL js/file-system-race, high) — the path
|
|
207
|
+
// can change between the two calls, and the code then acts on a stale answer.
|
|
208
|
+
// It is also lossy in a way that matters here: `existsSync` returns false for
|
|
209
|
+
// a path that exists but cannot be stat'd, so a permission or type error was
|
|
210
|
+
// silently reinterpreted as "absent" and the script took its create branch.
|
|
211
|
+
//
|
|
212
|
+
// These helpers invert it: attempt the operation, and treat ONLY `ENOENT` as
|
|
213
|
+
// "not there". Every other error (EACCES, EISDIR, ELOOP, …) propagates, which
|
|
214
|
+
// is both race-free and strictly more informative. One syscall, not two.
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read a UTF-8 file, or `null` when it does not exist.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} path
|
|
220
|
+
* @returns {string|null}
|
|
221
|
+
*/
|
|
222
|
+
function readFileIfPresent(path) {
|
|
223
|
+
try {
|
|
224
|
+
return readFileSync(path, "utf8");
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err.code === "ENOENT") return null;
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* List a directory's entries, or `null` when it does not exist.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} path
|
|
235
|
+
* @returns {string[]|null}
|
|
236
|
+
*/
|
|
237
|
+
function readdirIfPresent(path) {
|
|
238
|
+
try {
|
|
239
|
+
return readdirSync(path);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err.code === "ENOENT") return null;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
200
246
|
// ---------------------------------------------------------------------------
|
|
201
247
|
// Defaults requiring resolution
|
|
202
248
|
// ---------------------------------------------------------------------------
|
|
@@ -282,8 +328,9 @@ function resolveSha() {
|
|
|
282
328
|
/** Recursively collect `.yml`/`.yaml` files under a directory. */
|
|
283
329
|
function collectYaml(dir) {
|
|
284
330
|
const found = [];
|
|
285
|
-
|
|
286
|
-
|
|
331
|
+
const entries = readdirIfPresent(dir);
|
|
332
|
+
if (entries === null) return found;
|
|
333
|
+
for (const entry of entries) {
|
|
287
334
|
const full = join(dir, entry);
|
|
288
335
|
const st = statSync(full);
|
|
289
336
|
if (st.isDirectory()) found.push(...collectYaml(full));
|
|
@@ -401,17 +448,18 @@ function materializeRunbooks() {
|
|
|
401
448
|
const created = [];
|
|
402
449
|
const skipped = [];
|
|
403
450
|
const localCopies = [];
|
|
404
|
-
|
|
451
|
+
const templates = readdirIfPresent(runbookTemplatesDir);
|
|
452
|
+
if (templates === null) {
|
|
405
453
|
fail(`runbook templates not found at ${runbookTemplatesDir}.`);
|
|
406
454
|
}
|
|
407
455
|
const destDir = join(opts.consumer, "docs", "runbooks");
|
|
408
|
-
for (const entry of
|
|
456
|
+
for (const entry of templates) {
|
|
409
457
|
if (!entry.endsWith(".md")) continue;
|
|
410
458
|
if (entry.toLowerCase() === "readme.md") continue; // index, not a stub
|
|
411
459
|
const src = join(runbookTemplatesDir, entry);
|
|
412
460
|
const dest = join(destDir, entry);
|
|
413
|
-
|
|
414
|
-
|
|
461
|
+
const body = readFileIfPresent(dest);
|
|
462
|
+
if (body !== null) {
|
|
415
463
|
if (body.includes(STUB_MARKER)) {
|
|
416
464
|
skipped.push(rel(dest)); // already a reference stub — idempotent no-op
|
|
417
465
|
} else {
|
|
@@ -451,16 +499,19 @@ function materializeWorkflowStubs() {
|
|
|
451
499
|
const created = [];
|
|
452
500
|
const skipped = [];
|
|
453
501
|
const localCopies = [];
|
|
454
|
-
|
|
502
|
+
// Unlike the runbook templates above, an absent workflow-template directory
|
|
503
|
+
// is a soft no-op rather than a fatal — preserved exactly.
|
|
504
|
+
const templates = readdirIfPresent(workflowTemplatesDir);
|
|
505
|
+
if (templates === null) {
|
|
455
506
|
return { created, skipped, localCopies };
|
|
456
507
|
}
|
|
457
508
|
const destDir = join(opts.consumer, ".github", "workflows");
|
|
458
|
-
for (const entry of
|
|
509
|
+
for (const entry of templates) {
|
|
459
510
|
if (!/\.ya?ml$/.test(entry)) continue;
|
|
460
511
|
const src = join(workflowTemplatesDir, entry);
|
|
461
512
|
const dest = join(destDir, entry);
|
|
462
|
-
|
|
463
|
-
|
|
513
|
+
const body = readFileIfPresent(dest);
|
|
514
|
+
if (body !== null) {
|
|
464
515
|
if (body.includes(WORKFLOW_TEMPLATE_MARKER)) {
|
|
465
516
|
skipped.push(rel(dest)); // already materialized — idempotent no-op
|
|
466
517
|
} else {
|
|
@@ -501,11 +552,26 @@ function reconcileRenovate() {
|
|
|
501
552
|
".github/renovate.json",
|
|
502
553
|
".renovaterc.json",
|
|
503
554
|
].map((p) => join(opts.consumer, p));
|
|
504
|
-
|
|
505
|
-
|
|
555
|
+
// Read-through rather than find-then-read: the first candidate that yields
|
|
556
|
+
// content IS the config, with no window in which it can vanish between the
|
|
557
|
+
// probe and the read.
|
|
558
|
+
let path = null;
|
|
559
|
+
let raw = null;
|
|
560
|
+
for (const candidate of candidates) {
|
|
561
|
+
try {
|
|
562
|
+
raw = readFileIfPresent(candidate);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
fail(`could not read Renovate config at ${rel(candidate)}: ${err.message}`);
|
|
565
|
+
}
|
|
566
|
+
if (raw !== null) {
|
|
567
|
+
path = candidate;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (path === null) return { action: "absent", file: null };
|
|
506
572
|
let cfg;
|
|
507
573
|
try {
|
|
508
|
-
cfg = parseJsonc(
|
|
574
|
+
cfg = parseJsonc(raw);
|
|
509
575
|
} catch (err) {
|
|
510
576
|
fail(`could not parse Renovate config at ${rel(path)}: ${err.message}`);
|
|
511
577
|
}
|
|
@@ -521,10 +587,18 @@ function reconcileRenovate() {
|
|
|
521
587
|
|
|
522
588
|
function reconcileTsconfig() {
|
|
523
589
|
const path = join(opts.consumer, "tsconfig.json");
|
|
524
|
-
|
|
590
|
+
// Read and parse failures are reported separately: conflating them (as the
|
|
591
|
+
// old check-then-read did) reported an unreadable file as a parse error.
|
|
592
|
+
let raw;
|
|
593
|
+
try {
|
|
594
|
+
raw = readFileIfPresent(path);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
fail(`could not read tsconfig at ${rel(path)}: ${err.message}`);
|
|
597
|
+
}
|
|
598
|
+
if (raw === null) return { action: "absent", file: null };
|
|
525
599
|
let cfg;
|
|
526
600
|
try {
|
|
527
|
-
cfg = parseJsonc(
|
|
601
|
+
cfg = parseJsonc(raw);
|
|
528
602
|
} catch (err) {
|
|
529
603
|
fail(`could not parse tsconfig at ${rel(path)}: ${err.message}`);
|
|
530
604
|
}
|
|
@@ -642,3 +642,128 @@ test("a hand-authored deploy-staging.yml is flagged, not overwritten", () => {
|
|
|
642
642
|
"operator's hand-authored caller is never clobbered"
|
|
643
643
|
);
|
|
644
644
|
});
|
|
645
|
+
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// Filesystem access — perform, don't pre-check (Story #337)
|
|
648
|
+
//
|
|
649
|
+
// Every read used to be guarded by `existsSync(p)` before `readFileSync(p)` —
|
|
650
|
+
// a time-of-check/time-of-use race (CodeQL js/file-system-race, high) that
|
|
651
|
+
// also collapsed "unreadable" into "absent". These pin the replacement
|
|
652
|
+
// contract: ENOENT still means absent, and every other error is reported as a
|
|
653
|
+
// READ failure rather than being mistaken for a missing file or a parse error.
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
/** Run the CLI expecting a non-zero exit; return the combined output. */
|
|
657
|
+
function runExpectingFailure(extraArgs) {
|
|
658
|
+
try {
|
|
659
|
+
run(extraArgs);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
return `${err.stdout ?? ""}${err.stderr ?? ""}`;
|
|
662
|
+
}
|
|
663
|
+
assert.fail("expected the CLI to exit non-zero");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
test("an absent tsconfig is reported absent, not as an error", () => {
|
|
667
|
+
rmSync(join(consumer, "tsconfig.json"));
|
|
668
|
+
const out = JSON.parse(run([]));
|
|
669
|
+
assert.equal(out.tsconfig.action, "absent");
|
|
670
|
+
assert.equal(out.tsconfig.file, null);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("an absent renovate config is reported absent, not as an error", () => {
|
|
674
|
+
rmSync(join(consumer, "renovate.json"));
|
|
675
|
+
const out = JSON.parse(run([]));
|
|
676
|
+
assert.equal(out.renovate.action, "absent");
|
|
677
|
+
assert.equal(out.renovate.file, null);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("an unreadable tsconfig fails as a READ error, never as absent", () => {
|
|
681
|
+
// A directory where the file should be is the portable stand-in for an
|
|
682
|
+
// unreadable path (EISDIR). The old shape reported this as a *parse*
|
|
683
|
+
// failure, because the read happened inside the parse try/catch.
|
|
684
|
+
rmSync(join(consumer, "tsconfig.json"));
|
|
685
|
+
mkdirSync(join(consumer, "tsconfig.json"));
|
|
686
|
+
const out = runExpectingFailure([]);
|
|
687
|
+
assert.match(out, /could not read tsconfig/);
|
|
688
|
+
assert.doesNotMatch(out, /could not parse tsconfig/);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("an unreadable renovate config fails as a READ error, never as absent", () => {
|
|
692
|
+
rmSync(join(consumer, "renovate.json"));
|
|
693
|
+
mkdirSync(join(consumer, "renovate.json"));
|
|
694
|
+
const out = runExpectingFailure([]);
|
|
695
|
+
assert.match(out, /could not read Renovate config/);
|
|
696
|
+
assert.doesNotMatch(out, /could not parse Renovate config/);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test("a malformed tsconfig still fails as a PARSE error", () => {
|
|
700
|
+
// The read/parse split must not blur the other way either.
|
|
701
|
+
writeFileSync(join(consumer, "tsconfig.json"), "{ not json at all");
|
|
702
|
+
const out = runExpectingFailure([]);
|
|
703
|
+
assert.match(out, /could not parse tsconfig/);
|
|
704
|
+
assert.doesNotMatch(out, /could not read tsconfig/);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("an existing runbook stub is skipped, an unreadable one is not silently created", () => {
|
|
708
|
+
// First pass materializes; second must skip via the read, not a pre-check.
|
|
709
|
+
run([]);
|
|
710
|
+
const stub = join(consumer, "docs", "runbooks", "observability.md");
|
|
711
|
+
const body = readFileSync(stub, "utf8");
|
|
712
|
+
const out = JSON.parse(run([]));
|
|
713
|
+
assert.ok(
|
|
714
|
+
out.runbooks.skipped.some((f) => f.endsWith("observability.md")),
|
|
715
|
+
"already-materialized stub is skipped on the second pass"
|
|
716
|
+
);
|
|
717
|
+
assert.equal(readFileSync(stub, "utf8"), body, "skipped stub is byte-identical");
|
|
718
|
+
assert.equal(out.runbooks.created.length, 0);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("no read in the sync path is guarded by a prior existence check", () => {
|
|
722
|
+
// The regression guard for the defect class itself: `existsSync` may survive
|
|
723
|
+
// only as the import and the one CLI-argument precondition that has no
|
|
724
|
+
// paired read. Anything else is a reintroduced check-then-use race.
|
|
725
|
+
const source = readFileSync(join(__dirname, "platform-sync.mjs"), "utf8");
|
|
726
|
+
const uses = source
|
|
727
|
+
.split("\n")
|
|
728
|
+
.map((line, i) => ({ line, n: i + 1 }))
|
|
729
|
+
.filter(({ line }) => /(?<![A-Za-z])existsSync\s*\(/.test(line))
|
|
730
|
+
.filter(({ line }) => !/^\s*(\/\/|\*)/.test(line));
|
|
731
|
+
assert.equal(
|
|
732
|
+
uses.length,
|
|
733
|
+
1,
|
|
734
|
+
`expected exactly one existsSync call site (the --consumer precondition); found: ${JSON.stringify(
|
|
735
|
+
uses
|
|
736
|
+
)}`
|
|
737
|
+
);
|
|
738
|
+
assert.match(uses[0].line, /opts\.consumer/);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("a missing runbook-template dir fails with the message naming it, not a raw ENOENT", () => {
|
|
742
|
+
// The precondition is fatal by design. Converting the guard to a
|
|
743
|
+
// perform-then-classify read must not degrade it to an unhandled ENOENT.
|
|
744
|
+
const emptyTemplates = mkdtempSync(join(tmpdir(), "platform-sync-templates-"));
|
|
745
|
+
try {
|
|
746
|
+
const out = runExpectingFailure(["--templates", emptyTemplates]);
|
|
747
|
+
assert.match(out, /runbook templates not found at/);
|
|
748
|
+
assert.ok(out.includes(join(emptyTemplates, "runbooks")), "names the directory it looked in");
|
|
749
|
+
assert.doesNotMatch(out, /ENOENT/, "no raw errno leaks to the operator");
|
|
750
|
+
} finally {
|
|
751
|
+
rmSync(emptyTemplates, { recursive: true, force: true });
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("a missing workflow-template dir is a soft no-op, not a failure", () => {
|
|
756
|
+
// Deliberately NOT symmetrical with the runbook precondition above: an
|
|
757
|
+
// absent workflow-template directory yields an empty result set rather than
|
|
758
|
+
// a fatal. Pinned so the read conversion cannot quietly make it fatal.
|
|
759
|
+
const templates = mkdtempSync(join(tmpdir(), "platform-sync-templates-"));
|
|
760
|
+
try {
|
|
761
|
+
mkdirSync(join(templates, "runbooks"), { recursive: true });
|
|
762
|
+
const out = JSON.parse(run(["--templates", templates]));
|
|
763
|
+
assert.deepEqual(out.workflowStubs.created, []);
|
|
764
|
+
assert.deepEqual(out.workflowStubs.skipped, []);
|
|
765
|
+
assert.deepEqual(out.workflowStubs.localCopies, []);
|
|
766
|
+
} finally {
|
|
767
|
+
rmSync(templates, { recursive: true, force: true });
|
|
768
|
+
}
|
|
769
|
+
});
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
14
|
-
#
|
|
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.
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
#
|
|
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)
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# runner's in-flight download is
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|