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.
- package/package.json +1 -1
- package/scripts/check-ci-required-aggregator.test.mjs +227 -0
- package/scripts/check-coverage-threshold.test.mjs +56 -51
- package/scripts/check-destructive-migration.mjs +68 -6
- package/scripts/check-destructive-migration.test.mjs +146 -0
- package/scripts/deploy-boot-smoke.mjs +364 -0
- package/scripts/deploy-boot-smoke.test.mjs +381 -0
- package/scripts/deploy-worker-secrets.mjs +190 -0
- package/scripts/deploy-worker-secrets.test.mjs +136 -0
- package/templates/runbooks/README.md +13 -0
- package/templates/runbooks/runner-provisioning.md +187 -0
- package/templates/runner/.env.example +45 -0
- package/templates/runner/job-cleanup.sh +97 -0
- package/templates/workflows/deploy-staging.yml +11 -8
|
@@ -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 ->
|
|
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
|
|
20
|
-
#
|
|
21
|
-
# workflow is the CALLER's event, so the
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
|
50
|
-
# upstream conclusion was 'success' — no
|
|
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]
|