mandrel-platform 0.20.0 → 0.21.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.
@@ -0,0 +1,136 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ parseCsv,
6
+ parseSecretNames,
7
+ resolveSecretValue,
8
+ provisionWorkerSecrets,
9
+ } from "./deploy-worker-secrets.mjs";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Pure helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ test("parseCsv trims entries and drops empties", () => {
16
+ assert.deepEqual(parseCsv(" api , worker-cron ,, "), ["api", "worker-cron"]);
17
+ assert.deepEqual(parseCsv(undefined), []);
18
+ });
19
+
20
+ test("parseSecretNames trims, skips blanks, and skips '#' comment lines", () => {
21
+ const raw = ["# deploy-critical only", "TURSO_AUTH_TOKEN", "", " UPSTREAM_API_KEY ", " # trailing note"].join("\n");
22
+ assert.deepEqual(parseSecretNames(raw), ["TURSO_AUTH_TOKEN", "UPSTREAM_API_KEY"]);
23
+ assert.deepEqual(parseSecretNames(""), []);
24
+ });
25
+
26
+ test("resolveSecretValue returns the value only for present, non-empty string entries", () => {
27
+ const ctx = { A: "value", B: "", C: 42 };
28
+ assert.equal(resolveSecretValue(ctx, "A"), "value");
29
+ assert.equal(resolveSecretValue(ctx, "B"), null);
30
+ assert.equal(resolveSecretValue(ctx, "C"), null);
31
+ assert.equal(resolveSecretValue(ctx, "MISSING"), null);
32
+ assert.equal(resolveSecretValue(null, "A"), null);
33
+ });
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // provisionWorkerSecrets — orchestration with an injected wrangler runner
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function collect() {
40
+ const lines = [];
41
+ const calls = [];
42
+ return {
43
+ lines,
44
+ calls,
45
+ log: (line) => lines.push(line),
46
+ runWrangler: (args, stdinValue) => {
47
+ calls.push({ args, stdinValue });
48
+ return 0;
49
+ },
50
+ };
51
+ }
52
+
53
+ const BASE_ENV = {
54
+ DEPLOY_ENV: "production",
55
+ DEPLOYED_WORKERS: "api,worker-cron",
56
+ WORKER_SECRETS: "TURSO_AUTH_TOKEN",
57
+ SECRETS_CONTEXT: JSON.stringify({ TURSO_AUTH_TOKEN: "s3cret" }),
58
+ };
59
+
60
+ test("provisions each name onto each worker via versions secret put, then promotes with versions deploy -y", () => {
61
+ const { log, runWrangler, calls } = collect();
62
+ const code = provisionWorkerSecrets(BASE_ENV, { log, runWrangler });
63
+ assert.equal(code, 0);
64
+
65
+ assert.deepEqual(
66
+ calls.map((c) => c.args.slice(0, 3).join(" ")),
67
+ [
68
+ "versions secret put", // api
69
+ "versions deploy --name", // api promote
70
+ "versions secret put", // worker-cron
71
+ "versions deploy --name", // worker-cron promote
72
+ ]
73
+ );
74
+
75
+ // put: value travels over stdin, never in argv.
76
+ const put = calls[0];
77
+ assert.deepEqual(put.args, ["versions", "secret", "put", "TURSO_AUTH_TOKEN", "--name", "api", "--env", "production"]);
78
+ assert.equal(put.stdinValue, "s3cret");
79
+
80
+ // promote: non-interactive -y, message names the Story.
81
+ const promote = calls[1];
82
+ assert.ok(promote.args.includes("-y"));
83
+ assert.ok(promote.args.includes("--message"));
84
+ assert.equal(promote.stdinValue, undefined);
85
+ });
86
+
87
+ test("secret values never appear in log output", () => {
88
+ const { log, runWrangler, lines } = collect();
89
+ provisionWorkerSecrets(BASE_ENV, { log, runWrangler });
90
+ assert.ok(lines.every((l) => !l.includes("s3cret")));
91
+ });
92
+
93
+ test("zero resolved names is a notice + exit 0 (nothing to provision)", () => {
94
+ const { log, runWrangler, calls, lines } = collect();
95
+ const code = provisionWorkerSecrets(
96
+ { ...BASE_ENV, WORKER_SECRETS: "\n# only comments\n\n" },
97
+ { log, runWrangler }
98
+ );
99
+ assert.equal(code, 0);
100
+ assert.equal(calls.length, 0);
101
+ assert.ok(lines.some((l) => l.includes("resolved to zero names")));
102
+ });
103
+
104
+ test("a listed name absent (or empty) in the inherited context is a hard error", () => {
105
+ const { log, runWrangler, lines } = collect();
106
+ const code = provisionWorkerSecrets(
107
+ { ...BASE_ENV, WORKER_SECRETS: "MISSING_SECRET" },
108
+ { log, runWrangler }
109
+ );
110
+ assert.equal(code, 1);
111
+ assert.ok(lines.some((l) => l.includes("'MISSING_SECRET' is not present (or empty)")));
112
+ });
113
+
114
+ test("malformed SECRETS_CONTEXT JSON is a hard error", () => {
115
+ const { log, runWrangler } = collect();
116
+ const code = provisionWorkerSecrets({ ...BASE_ENV, SECRETS_CONTEXT: "{not json" }, { log, runWrangler });
117
+ assert.equal(code, 1);
118
+ });
119
+
120
+ test("a failing wrangler versions secret put aborts with exit 1", () => {
121
+ const { log } = collect();
122
+ const code = provisionWorkerSecrets(BASE_ENV, {
123
+ log,
124
+ runWrangler: (args) => (args[1] === "secret" ? 1 : 0),
125
+ });
126
+ assert.equal(code, 1);
127
+ });
128
+
129
+ test("a failing wrangler versions deploy aborts with exit 1", () => {
130
+ const { log } = collect();
131
+ const code = provisionWorkerSecrets(BASE_ENV, {
132
+ log,
133
+ runWrangler: (args) => (args[0] === "versions" && args[1] === "deploy" ? 1 : 0),
134
+ });
135
+ assert.equal(code, 1);
136
+ });
@@ -54,3 +54,16 @@ node node_modules/mandrel-platform/scripts/platform-sync.mjs --ref mandrel-platf
54
54
 
55
55
  > Placeholder convention: `<UPPER_SNAKE>` between angle brackets. Search for
56
56
  > `<` after copying to find everything that still needs a value.
57
+
58
+ ## Self-contained runbooks (not stubs)
59
+
60
+ One template in this directory is a **full runbook**, not a thin stub — it
61
+ has no canonical `docs/runbooks/` counterpart to link and carries the entire
62
+ process inline (placeholders included):
63
+
64
+ | Runbook | Scope |
65
+ |---------|-------|
66
+ | `runner-provisioning.md` | provision a persistent self-hosted runner with the [`templates/runner/`](../runner/) hygiene kit (job-start cleanup hook + runner-scoped `.env`) |
67
+
68
+ Adopt it the same way as a stub (copy into `docs/runbooks/`, fill the
69
+ placeholders); `platform-sync.mjs` materializes it alongside the stubs.
@@ -0,0 +1,187 @@
1
+ # Self-Hosted Runner Provisioning — <PROJECT_NAME>
2
+
3
+ > **Self-contained runbook** (not a thin stub). Unlike the other templates in
4
+ > this directory, there is no canonical `docs/runbooks/` counterpart to link —
5
+ > this file IS the process. It provisions a **persistent** (non-ephemeral)
6
+ > GitHub Actions runner on a macOS host using the mandrel-platform runner kit
7
+ > (`templates/runner/`), which ships the job-start hygiene hook
8
+ > (`job-cleanup.sh`) and the per-runner `.env` (`.env.example`).
9
+ >
10
+ > Placeholder convention: `<UPPER_SNAKE>` between angle brackets — search for
11
+ > `<` after copying to find everything that still needs a value.
12
+
13
+ ---
14
+
15
+ ## ⚠️ Read first: the shared-`$HOME` concurrency hazard
16
+
17
+ Every runner on a host typically runs as the **same OS user**, so anything
18
+ resolved against `$HOME` is **shared across all co-resident runners** — it is
19
+ *not* scoped to "this runner", no matter what a comment claims. The two
20
+ hazards this kit exists to close:
21
+
22
+ 1. **`~/setup-pnpm` is shared.** `pnpm/action-setup`'s `dest` input defaults
23
+ to `~/setup-pnpm`. With N runners on one host, N concurrent jobs race on
24
+ one pnpm shim install. Worse, a cleanup hook that `pkill`s processes
25
+ matching `~/setup-pnpm` or `rm -rf`s it at job start will **destroy a pnpm
26
+ install a concurrent runner is mid-flight on**. The fix is ordering:
27
+ **first** make the pnpm shim location per-runner (workflow-side, see
28
+ [pnpm scoping](#3-pnpm-scoping-workflow-side-prerequisite) below), and only
29
+ **then** is a reap/delete hook safe — and even then it must target only the
30
+ runner-scoped path. The kit's `job-cleanup.sh` never touches
31
+ `~/setup-pnpm`.
32
+ 2. **The default tool cache is shared.** Without a runner-scoped
33
+ `RUNNER_TOOL_CACHE`, toolchain actions extract into a host-shared cache
34
+ and co-resident runners race on it. The kit's `.env` scopes it to the
35
+ runner's own `_work/_tool`.
36
+
37
+ Do not roll the hook out to a host until every workflow that job's runner
38
+ serves installs pnpm to a runner-scoped `dest`. Rolling out the hook without
39
+ the pnpm-scoping prerequisite reintroduces the exact corruption it guards
40
+ against.
41
+
42
+ ## Host Values
43
+
44
+ | Value | Setting |
45
+ |-------|---------|
46
+ | Runner host | `<RUNNER_HOST>` |
47
+ | Runner OS user | `<RUNNER_USER>` |
48
+ | Runner root dir | `<RUNNER_DIR>` (e.g. `~/Development/github-runners/<REPO>`) |
49
+ | Repository | `<OWNER>/<REPO>` |
50
+ | Runner name | `<REPO>-runner` (or `<REPO>-runner-<N>` for a pool) |
51
+ | Labels | `self-hosted, macOS, ARM64, <REPO>-runner` |
52
+ | Runner version | `<RUNNER_VERSION>` (latest from [actions/runner releases](https://github.com/actions/runner/releases)) |
53
+
54
+ ## 1. Download and unpack the runner
55
+
56
+ One directory per runner — never share a runner root between registrations.
57
+
58
+ ```bash
59
+ mkdir -p <RUNNER_DIR> && cd <RUNNER_DIR>
60
+ curl -o actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz -L \
61
+ https://github.com/actions/runner/releases/download/v<RUNNER_VERSION>/actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz
62
+ # Verify the SHA-256 against the checksum published on the release page
63
+ # before unpacking — same download-and-verify posture as the platform's
64
+ # pinned gitleaks/actionlint installs.
65
+ shasum -a 256 actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz
66
+ tar xzf actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz
67
+ ```
68
+
69
+ ## 2. Register with `config.sh` (repo-level)
70
+
71
+ Registration is **repo-level** (the fleet's standing model), not org-level.
72
+ Mint a short-lived registration token via the repo UI
73
+ (*Settings → Actions → Runners → New self-hosted runner*) or:
74
+
75
+ ```bash
76
+ gh api -X POST repos/<OWNER>/<REPO>/actions/runners/registration-token --jq .token
77
+ ```
78
+
79
+ Then configure:
80
+
81
+ ```bash
82
+ cd <RUNNER_DIR>
83
+ ./config.sh \
84
+ --url https://github.com/<OWNER>/<REPO> \
85
+ --token <REGISTRATION_TOKEN> \
86
+ --name <REPO>-runner \
87
+ --labels self-hosted,macOS,ARM64,<REPO>-runner \
88
+ --work _work \
89
+ --unattended
90
+ ```
91
+
92
+ - `--labels` — the four-label contract the fleet's workflows target
93
+ (`self-hosted, macOS, ARM64, <REPO>-runner`). The `<REPO>-runner` label is
94
+ the routing key; keep it unique per repo.
95
+ - `--work _work` — keeps the work tree inside `<RUNNER_DIR>`, which is what
96
+ makes every path in the hygiene kit runner-scoped.
97
+ - Registration tokens expire after ~1 hour; mint a fresh one per runner.
98
+
99
+ ## 3. pnpm scoping (workflow-side prerequisite)
100
+
101
+ Before installing the hook, confirm every workflow this runner serves
102
+ installs the pnpm shim to a **runner-scoped** destination:
103
+
104
+ - Workflows using the platform's `setup-toolchain` composite action (all
105
+ `pr-quality.yml` tiers) are already safe: it defaults `pnpm/action-setup`'s
106
+ `dest` to `${{ runner.temp }}/pnpm`, i.e. `<RUNNER_DIR>/_work/_temp/pnpm`,
107
+ unique per runner. `pr-quality.yml` also exposes a `pnpm-dest` input for
108
+ explicit overrides.
109
+ - Workflows calling `pnpm/action-setup` directly MUST pass
110
+ `dest: ${{ runner.temp }}/pnpm`. The action's default (`~/setup-pnpm`) is
111
+ host-shared and unsafe under runner concurrency (see the hazard header).
112
+
113
+ There is **no runner-side override** for the pnpm `dest` — it is a workflow
114
+ input — which is why this step is a rollout gate, not an `.env` line.
115
+
116
+ ## 4. Install the hygiene kit (hook + `.env`)
117
+
118
+ Copy the kit from the platform payload into the runner root:
119
+
120
+ ```bash
121
+ cp node_modules/mandrel-platform/templates/runner/job-cleanup.sh <RUNNER_DIR>/job-cleanup.sh
122
+ chmod +x <RUNNER_DIR>/job-cleanup.sh
123
+ cp node_modules/mandrel-platform/templates/runner/.env.example <RUNNER_DIR>/.env
124
+ ```
125
+
126
+ Then edit `<RUNNER_DIR>/.env` and replace every `<RUNNER_DIR>` placeholder
127
+ with the runner root's absolute path. The resulting file wires:
128
+
129
+ - `ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh` — the
130
+ job-start hook. It reaps orphaned pnpm/node processes parented to **this**
131
+ runner's work tree, clears stale runner-scoped pnpm installs, and
132
+ age-gate-sweeps shared-`$TMPDIR` gitleaks leftovers. It never fails a job
133
+ (always exits 0) and never touches another runner's state.
134
+ - `RUNNER_TOOL_CACHE=<RUNNER_DIR>/_work/_tool` and
135
+ `AGENT_TOOLSDIRECTORY=<RUNNER_DIR>/_work/_tool` — runner-scoped tool cache
136
+ (two env names, one dir; some actions read the legacy name).
137
+ - `LANG=en_US.UTF-8`.
138
+
139
+ The hook needs no per-runner editing: it derives `RUNNER_DIR` from its own
140
+ location, so the same file works verbatim on every runner.
141
+
142
+ ## 5. Install as a launchd service (`svc.sh`)
143
+
144
+ ```bash
145
+ cd <RUNNER_DIR>
146
+ ./svc.sh install # generates the launchd plist for the current user
147
+ ./svc.sh start
148
+ ./svc.sh status # expect: Started · running
149
+ ```
150
+
151
+ Verify end-to-end: push a trivial workflow run targeting
152
+ `runs-on: [self-hosted, macOS, ARM64, <REPO>-runner]` and confirm (a) the job
153
+ is picked up and (b) the job log shows the `Set up runner` hook phase running
154
+ `job-cleanup.sh` before the first step.
155
+
156
+ The runner loads `.env` at service start — after any `.env` change, restart:
157
+
158
+ ```bash
159
+ ./svc.sh stop && ./svc.sh start
160
+ ```
161
+
162
+ ## 6. Update / rotation guidance
163
+
164
+ - **Runner version updates.** Persistent runners self-update by default when
165
+ GitHub releases a new runner version; no action needed. If a runner is
166
+ pinned or the self-update wedges, stop the service, download/unpack the new
167
+ tarball over `<RUNNER_DIR>` (config and `.env` survive), and restart via
168
+ `svc.sh`.
169
+ - **Kit updates.** The hook and `.env.example` are versioned in
170
+ mandrel-platform. On a platform release that touches `templates/runner/`,
171
+ re-copy `job-cleanup.sh` (verbatim — it is parameterized) and diff
172
+ `.env.example` against the live `.env`, then `./svc.sh stop && ./svc.sh
173
+ start`. There is no `mandrel sync` equivalent for a runner host's
174
+ filesystem — this is an operator-applied step.
175
+ - **Token/registration rotation.** Registration tokens are one-shot at
176
+ config time; nothing persists to rotate. To move a runner between repos or
177
+ rename it: `./svc.sh stop && ./svc.sh uninstall && ./config.sh remove
178
+ --token <REMOVAL_TOKEN>`, then re-register (§2) and reinstall the service
179
+ (§5). Mint the removal token via
180
+ `gh api -X POST repos/<OWNER>/<REPO>/actions/runners/remove-token --jq .token`.
181
+ - **Decommission.** Same removal sequence, then delete `<RUNNER_DIR>`.
182
+ Confirm the runner disappeared from *Settings → Actions → Runners*.
183
+
184
+ ## Project-Specific Notes
185
+
186
+ <!-- Record host quirks: co-resident runner inventory for this host, Xcode /
187
+ toolchain versions the workloads assume, monitoring hooks, etc. -->
@@ -0,0 +1,45 @@
1
+ # .env — per-runner environment for a PERSISTENT self-hosted runner
2
+ # (mandrel-platform runner kit).
3
+ #
4
+ # Copy this file to `<RUNNER_DIR>/.env` (the runner root, next to config.sh)
5
+ # and replace every `<RUNNER_DIR>` placeholder with the runner's ABSOLUTE
6
+ # root path (e.g. /Users/ci/Development/github-runners/myrepo). The runner
7
+ # loads this file at service start and injects the variables into every job.
8
+ #
9
+ # Placeholder convention: `<UPPER_SNAKE>` between angle brackets — search for
10
+ # `<` after copying to find everything that still needs a value.
11
+ #
12
+ # See templates/runbooks/runner-provisioning.md for the full provisioning
13
+ # procedure, including WHY every value here must be runner-scoped (multiple
14
+ # runners on one host share an OS user, so anything resolved against $HOME is
15
+ # a cross-runner concurrency hazard).
16
+
17
+ # Locale — pnpm/git/node emit UTF-8; a C locale garbles their output.
18
+ LANG=en_US.UTF-8
19
+
20
+ # Job-start hygiene hook. Runs templates/runner/job-cleanup.sh (installed
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).
24
+ ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh
25
+
26
+ # Runner-scoped tool cache. Without this, actions/setup-node & friends
27
+ # default the tool cache to a host-shared location and co-resident runners
28
+ # race on extraction. `_work/_tool` is inside this runner's own work tree,
29
+ # so each runner gets an isolated cache.
30
+ RUNNER_TOOL_CACHE=<RUNNER_DIR>/_work/_tool
31
+
32
+ # Same value, second consumer: some toolchain actions read
33
+ # AGENT_TOOLSDIRECTORY (the Azure Pipelines-era name) instead of
34
+ # RUNNER_TOOL_CACHE. Keep both pointing at the same runner-scoped dir.
35
+ AGENT_TOOLSDIRECTORY=<RUNNER_DIR>/_work/_tool
36
+
37
+ # ── pnpm scoping (read this before enabling the hook) ──────────────────────
38
+ # The pnpm shim install location is a WORKFLOW-side setting, not a runner-side
39
+ # one: pnpm/action-setup's `dest` input defaults to `~/setup-pnpm`, which is
40
+ # SHARED across every runner on the host. The platform's setup-toolchain
41
+ # composite action already re-defaults `dest` to `${{ runner.temp }}/pnpm`
42
+ # (runner-scoped); workflows that call pnpm/action-setup directly MUST pass
43
+ # `dest: ${{ runner.temp }}/pnpm` (or the pr-quality.yml `pnpm-dest` input).
44
+ # The job-cleanup.sh hook only reaps the runner-scoped locations — it
45
+ # deliberately never touches `~/setup-pnpm`.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # ACTIONS_RUNNER_HOOK_JOB_STARTED hook — generalized, runner-scoped hygiene
4
+ # for PERSISTENT self-hosted runners (mandrel-platform runner kit).
5
+ #
6
+ # A persistent runner (launchd/systemd service) leaks state from a prior job
7
+ # into the next one: the runner does not always reap the job's child process
8
+ # tree, and some actions leave files in shared locations. Observed breakage
9
+ # classes this hook guards against:
10
+ #
11
+ # - an orphaned `pnpm`/`node` process (e.g. a hung install or lint) still
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.
15
+ #
16
+ # Running this before every job gives each job a clean slate ("fresh per job"
17
+ # without the cost of re-registering an ephemeral runner).
18
+ #
19
+ # ── CONCURRENCY SAFETY (the load-bearing design constraint) ─────────────────
20
+ #
21
+ # Multiple runners on one host typically run as the SAME OS user, so anything
22
+ # under $HOME (notably `~/setup-pnpm`, the pnpm/action-setup DEFAULT install
23
+ # destination) is SHARED across all co-resident runners. A hook that reaps
24
+ # processes matching `~/setup-pnpm` or `rm -rf`s it will destroy a pnpm
25
+ # install a CONCURRENT runner is mid-flight on. This hook therefore:
26
+ #
27
+ # 1. NEVER touches `~/setup-pnpm` or any other $HOME-shared pnpm path.
28
+ # The pnpm shim MUST instead be runner-scoped at install time: the
29
+ # platform's `setup-toolchain` composite action already defaults
30
+ # pnpm/action-setup's `dest` to `${{ runner.temp }}/pnpm` (i.e.
31
+ # `<RUNNER_DIR>/_work/_temp/pnpm`, unique per runner), and
32
+ # `pr-quality.yml` exposes a `pnpm-dest` input for explicit overrides.
33
+ # See templates/runbooks/runner-provisioning.md § "pnpm scoping".
34
+ # 2. Reaps ONLY processes whose command line resolves inside THIS runner's
35
+ # own work tree (`<RUNNER_DIR>/_work/...`). Every path below derives
36
+ # from RUNNER_DIR, which is unique per runner, so a co-resident
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.
41
+ #
42
+ # ── PARAMETERIZATION ────────────────────────────────────────────────────────
43
+ #
44
+ # No hardcoded usernames, repo names, or runner names. All paths derive from:
45
+ #
46
+ # RUNNER_DIR — the runner's root directory. Defaults to the directory
47
+ # containing this script (the kit installs the hook into the
48
+ # runner root, next to config.sh / run.sh). Override via env
49
+ # only if you install the hook elsewhere.
50
+ # 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.
56
+ #
57
+ # Configured via `ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh`
58
+ # in the runner's `.env` (see .env.example in this directory).
59
+ #
60
+ # NEVER fails the job — best-effort cleanup, always exits 0.
61
+
62
+ set +e
63
+
64
+ RUNNER_DIR="${RUNNER_DIR:-$(cd "$(dirname "$0")" && pwd)}"
65
+ RUNNER_WORK="${RUNNER_DIR}/_work"
66
+ RUNNER_TMP="${RUNNER_WORK}/_temp"
67
+ TMP="${TMPDIR:-/tmp}"
68
+ STALE_MINUTES="${JOB_CLEANUP_STALE_MINUTES:-60}"
69
+
70
+ # 1) Reap orphaned pnpm/node processes from prior jobs — scoped to THIS
71
+ # runner's work tree only. The patterns target executable paths INSIDE the
72
+ # runner-scoped install locations (`.../node_modules`), so they match the
73
+ # actual pnpm/node binaries that ran from these dirs — not a shell that
74
+ # merely references the path. RUNNER_TMP and RUNNER_WORK are unique per
75
+ # runner, so co-resident runners and unrelated user processes are never
76
+ # hit. The shared `~/setup-pnpm` is deliberately NOT a reap target (see
77
+ # the concurrency-safety header).
78
+ pkill -9 -f "${RUNNER_TMP}/pnpm/node_modules" 2>/dev/null
79
+ pkill -9 -f "${RUNNER_TMP}/setup-pnpm/node_modules" 2>/dev/null
80
+ pkill -9 -f "${RUNNER_WORK}/_tool/[^ ]*node_modules" 2>/dev/null
81
+
82
+ # 2) Remove stale runner-scoped pnpm shim installs so the next job's
83
+ # pnpm/action-setup starts from a clean slate. Only paths under THIS
84
+ # runner's `_work/_temp` are deleted — never `~/setup-pnpm`.
85
+ rm -rf "${RUNNER_TMP}/pnpm" 2>/dev/null
86
+ rm -rf "${RUNNER_TMP}/setup-pnpm" 2>/dev/null
87
+
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
96
+
97
+ exit 0
@@ -3,7 +3,7 @@ name: deploy-staging
3
3
  # Canonical staging-deploy caller template (Story #175).
4
4
  #
5
5
  # > **Thin local caller.** The defence-in-depth deploy core (secret-isolation
6
- # > audit -> CF env gate -> pre-migration snapshot -> migrate -> deploy ->
6
+ # > audit -> CF env gate -> migration (snapshot + apply) -> deploy ->
7
7
  # > boot-smoke + auto-rollback) AND the CI-green guard both live in the shared
8
8
  # > `dsj1984/mandrel-platform` `deploy-cloudflare.yml` reusable workflow — see
9
9
  # > https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml.
@@ -16,11 +16,13 @@ name: deploy-staging
16
16
  # `conclusion == 'success'`. `workflow_run` fires on BOTH a successful AND a
17
17
  # failed upstream run, so a caller-side guard against a red run used to be
18
18
  # REQUIRED here — every consumer hand-copied the same `preflight` job (see
19
- # mandrel-platform Story #175 context). That guard is now a `require-ci-green`
20
- # job INSIDE `deploy-cloudflare.yml` itself (`github.event` inside a reusable
21
- # workflow is the CALLER's event, so the shared workflow can see and gate on
22
- # the `workflow_run` conclusion even though it cannot own this file's `on:`
23
- # block). This template needs NO caller-side preflight guard as a result
19
+ # mandrel-platform Story #175 context). That guard now lives INSIDE
20
+ # `deploy-cloudflare.yml` itself as a job-level `if:` on its entry jobs
21
+ # (`github.event` inside a reusable workflow is the CALLER's event, so the
22
+ # shared workflow can see and gate on the `workflow_run` conclusion even
23
+ # though it cannot own this file's `on:` block); a red upstream run skips
24
+ # the whole chain with zero runner spin-ups (mandrel-platform Story #237).
25
+ # This template needs NO caller-side preflight guard as a result —
24
26
  # copy it as-is and fill in the placeholders below.
25
27
  #
26
28
  # Replace every <PLACEHOLDER> with your project's real values:
@@ -46,8 +48,9 @@ name: deploy-staging
46
48
 
47
49
  on:
48
50
  # CI-green gate: fires when <CI_WORKFLOW_NAME> finishes on main. The shared
49
- # deploy-cloudflare.yml's require-ci-green job skips-with-notice unless the
50
- # upstream conclusion was 'success' — no caller-side guard needed.
51
+ # deploy-cloudflare.yml's job-level CI-green gate skips the entire job
52
+ # chain (zero runners) unless the upstream conclusion was 'success' — no
53
+ # caller-side guard needed.
51
54
  workflow_run:
52
55
  workflows: [<CI_WORKFLOW_NAME>]
53
56
  branches: [main]