mandrel-platform 0.28.0 → 0.29.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-affected-mode.test.mjs — YAML-level regression guard for the
4
+ * affected-mode wiring in `.github/workflows/pr-quality.yml` (Story #319,
5
+ * follow-up to #314/#315).
6
+ *
7
+ * `scripts/resolve-diff-range.sh` has solid unit coverage
8
+ * (`resolve-diff-range.test.mjs`), but the affected-mode *wiring* inside the
9
+ * reusable workflow was only covered indirectly by anchor-expansion checks.
10
+ * This suite pins the three invariants that keep the affected-mode design
11
+ * correct, modelled on the sibling `check-ci-required-aggregator.test.mjs`
12
+ * (read the real workflow, extract blocks by indentation, assert):
13
+ *
14
+ * 1. GATING — the coverage-gate steps (`Checkout gate scripts`, `Coverage
15
+ * threshold gate`) carry `inputs.coverage-threshold != 0 && !inputs.affected`,
16
+ * and the bypass `::notice::` step carries the *complementary*
17
+ * `inputs.coverage-threshold != 0 && inputs.affected`. A drift that drops
18
+ * `!inputs.affected` (which would false-fail the whole-repo floor on an
19
+ * affected subset) fails this suite.
20
+ * 2. NO EXPORT ON mode=none — executing the resolve-affected `run:` script
21
+ * with an unresolved (`mode=none`) range leaves `TURBO_SCM_*` unset, so
22
+ * turbo falls back to its own default rather than a bogus range.
23
+ * 3. INJECTION-SAFE EXPORT (Story #319 hardening) — a newline-bearing
24
+ * `affected-base` override is rejected before the `$GITHUB_ENV` write, so
25
+ * it can never inject a second env line; a single-line override exports
26
+ * exactly `TURBO_SCM_BASE` / `TURBO_SCM_HEAD` and nothing else.
27
+ *
28
+ * The `run:` script is executed against real bash with a stubbed
29
+ * `resolve-diff-range.sh` (the sourced derivation), so no git repo is needed;
30
+ * skipped when bash is unavailable locally (CI's ubuntu runner always has it).
31
+ *
32
+ * Run: node --test scripts/check-affected-mode.test.mjs
33
+ */
34
+
35
+ import assert from "node:assert/strict";
36
+ import { test } from "node:test";
37
+ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
38
+ import { execFileSync, spawnSync } from "node:child_process";
39
+ import { join, resolve, dirname } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import { tmpdir } from "node:os";
42
+
43
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
44
+ const WORKFLOW = ".github/workflows/pr-quality.yml";
45
+ const content = readFileSync(join(repoRoot, WORKFLOW), "utf8");
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Minimal indentation-based extraction (dependency-free, mirrors
49
+ // check-ci-required-aggregator.test.mjs). A step spans from its `- ` bullet to
50
+ // the next sibling bullet at the same indent (or a dedent below it).
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function stepByName(text, name) {
54
+ const lines = text.split("\n");
55
+ const nameIdx = lines.findIndex((l) => /^\s+(- )?name:\s/.test(l) && l.includes(name));
56
+ assert.notEqual(nameIdx, -1, `step "${name}" not found`);
57
+ let start = -1;
58
+ for (let i = nameIdx; i >= 0; i--) {
59
+ if (/^\s*-\s/.test(lines[i])) {
60
+ start = i;
61
+ break;
62
+ }
63
+ }
64
+ assert.notEqual(start, -1, `opening bullet for step "${name}" not found`);
65
+ const bulletIndent = lines[start].match(/^(\s*)/)[1].length;
66
+ let end = lines.length;
67
+ for (let i = start + 1; i < lines.length; i++) {
68
+ if (/^\s*$/.test(lines[i])) continue;
69
+ const indent = lines[i].match(/^(\s*)/)[1].length;
70
+ if (indent < bulletIndent) {
71
+ end = i;
72
+ break;
73
+ }
74
+ if (indent === bulletIndent && /^\s*-\s/.test(lines[i])) {
75
+ end = i;
76
+ break;
77
+ }
78
+ }
79
+ return lines.slice(start, end).join("\n");
80
+ }
81
+
82
+ /** The (first) `if:` expression on a step block, trimmed. */
83
+ function ifCondition(stepBlock) {
84
+ const m = stepBlock.match(/^\s+if:\s*(.+?)\s*$/m);
85
+ assert.ok(m, "step has no `if:` condition");
86
+ return m[1].trim();
87
+ }
88
+
89
+ /** The dedented body of the step's `run: |` block scalar. */
90
+ function runScript(stepBlock) {
91
+ const lines = stepBlock.split("\n");
92
+ const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
93
+ assert.notEqual(start, -1, "`run: |` block not found");
94
+ const runIndent = lines[start].match(/^(\s*)/)[1].length;
95
+ const body = [];
96
+ for (let i = start + 1; i < lines.length; i++) {
97
+ if (/^\s*$/.test(lines[i])) {
98
+ body.push("");
99
+ continue;
100
+ }
101
+ const indent = lines[i].match(/^(\s*)/)[1].length;
102
+ if (indent <= runIndent) break;
103
+ body.push(lines[i].slice(runIndent + 2));
104
+ }
105
+ return body.join("\n");
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 1. GATING — coverage-gate steps gated OUT of affected mode; bypass step gated IN
110
+ // ---------------------------------------------------------------------------
111
+
112
+ test("resolve-affected step runs only in affected mode", () => {
113
+ const cond = ifCondition(stepByName(content, "Resolve affected SCM base/head (turbo)"));
114
+ assert.match(cond, /inputs\.affected/, "the resolve step must be gated on `inputs.affected`");
115
+ assert.doesNotMatch(cond, /!\s*inputs\.affected/, "the resolve step must not be negated");
116
+ });
117
+
118
+ for (const step of ["Checkout gate scripts (mandrel-platform@resolved-sha)", "Coverage threshold gate"]) {
119
+ test(`coverage-gate step "${step}" is gated OUT of affected mode`, () => {
120
+ const cond = ifCondition(stepByName(content, step));
121
+ assert.match(
122
+ cond,
123
+ /inputs\.coverage-threshold != 0/,
124
+ "must still require a non-zero coverage-threshold"
125
+ );
126
+ assert.match(
127
+ cond,
128
+ /!\s*inputs\.affected/,
129
+ "must carry `!inputs.affected` — dropping it false-fails the whole-repo floor on an affected subset"
130
+ );
131
+ });
132
+ }
133
+
134
+ test("bypass `::notice::` step carries the complementary affected-mode gating", () => {
135
+ const step = stepByName(content, "Coverage floor bypassed (affected mode)");
136
+ const cond = ifCondition(step);
137
+ assert.match(cond, /inputs\.coverage-threshold != 0/, "must require a non-zero coverage-threshold");
138
+ assert.match(cond, /&&\s*inputs\.affected/, "must fire only in affected mode");
139
+ assert.doesNotMatch(
140
+ cond,
141
+ /!\s*inputs\.affected/,
142
+ "the bypass notice must be the complement of the gate — never negated"
143
+ );
144
+ assert.match(step, /::notice title=/, "the bypass must emit a visible ::notice::, never a silent skip");
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // 2 & 3. Execute the resolve-affected run script against real bash.
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function bashAvailable() {
152
+ try {
153
+ execFileSync("bash", ["--version"], { stdio: "ignore" });
154
+ return true;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ const RESOLVE_SCRIPT = runScript(stepByName(content, "Resolve affected SCM base/head (turbo)"));
161
+
162
+ /**
163
+ * Run the extracted resolve-affected `run:` body with a stubbed
164
+ * resolve-diff-range.sh that sets RESOLVED_* from STUB_* env, capturing the
165
+ * lines the step wrote to $GITHUB_ENV.
166
+ */
167
+ function runResolve({ mode = "", base = "", head = "", override } = {}) {
168
+ const dir = mkdtempSync(join(tmpdir(), "affected-mode-"));
169
+ try {
170
+ const workspace = join(dir, "ws");
171
+ mkdirSync(join(workspace, "_mandrel-platform-range", "scripts"), { recursive: true });
172
+ // Stub the sourced derivation: RESOLVED_* come from STUB_* env.
173
+ writeFileSync(
174
+ join(workspace, "_mandrel-platform-range", "scripts", "resolve-diff-range.sh"),
175
+ ['RESOLVED_EVENT_MODE="${STUB_MODE-}"', 'RESOLVED_BASE_SHA="${STUB_BASE-}"', 'RESOLVED_HEAD_SHA="${STUB_HEAD-}"', ""].join("\n")
176
+ );
177
+ const scriptFile = join(dir, "resolve.sh");
178
+ writeFileSync(scriptFile, RESOLVE_SCRIPT);
179
+ const envFile = join(dir, "github_env");
180
+ writeFileSync(envFile, "");
181
+
182
+ const env = {
183
+ ...process.env,
184
+ GITHUB_WORKSPACE: workspace,
185
+ GITHUB_ENV: envFile,
186
+ STUB_MODE: mode,
187
+ STUB_BASE: base,
188
+ STUB_HEAD: head,
189
+ };
190
+ if (override !== undefined) env.AFFECTED_BASE_OVERRIDE = override;
191
+
192
+ const r = spawnSync("bash", [scriptFile], { encoding: "utf8", env });
193
+ return { status: r.status, stderr: r.stderr, envLines: readFileSync(envFile, "utf8") };
194
+ } finally {
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ }
198
+
199
+ const exec = { skip: bashAvailable() ? false : "bash not available on this host" };
200
+
201
+ test("mode=none: no ranged base resolvable → TURBO_SCM_* left unset", exec, () => {
202
+ const r = runResolve({ mode: "", base: "", head: "" });
203
+ assert.equal(r.status, 0, r.stderr);
204
+ assert.doesNotMatch(r.envLines, /TURBO_SCM_BASE/, "mode=none must not export a base");
205
+ assert.doesNotMatch(r.envLines, /TURBO_SCM_HEAD/, "mode=none must not export a head");
206
+ });
207
+
208
+ test("newline-bearing affected-base override is rejected before the export (injection guard)", exec, () => {
209
+ const r = runResolve({ override: "deadbeef\nMALICIOUS=pwned", head: "cafebabe" });
210
+ assert.notEqual(r.status, 0, "a multiline override must fail the step, not export");
211
+ assert.doesNotMatch(r.envLines, /MALICIOUS/, "the injected env line must never reach $GITHUB_ENV");
212
+ assert.doesNotMatch(r.envLines, /TURBO_SCM_BASE/, "no partial export on a rejected override");
213
+ });
214
+
215
+ test("carriage-return-bearing affected-base override is rejected too", exec, () => {
216
+ const r = runResolve({ override: "deadbeef\rMALICIOUS=pwned", head: "cafebabe" });
217
+ assert.notEqual(r.status, 0);
218
+ assert.doesNotMatch(r.envLines, /MALICIOUS/);
219
+ });
220
+
221
+ test("single-line affected-base override exports exactly TURBO_SCM_BASE and TURBO_SCM_HEAD", exec, () => {
222
+ const r = runResolve({ override: "origin/main", head: "cafebabe" });
223
+ assert.equal(r.status, 0, r.stderr);
224
+ assert.match(r.envLines, /^TURBO_SCM_BASE=origin\/main$/m);
225
+ assert.match(r.envLines, /^TURBO_SCM_HEAD=cafebabe$/m);
226
+ const nonEmpty = r.envLines.split("\n").filter((l) => l.trim() !== "");
227
+ assert.equal(nonEmpty.length, 2, `expected exactly two env lines, got: ${JSON.stringify(nonEmpty)}`);
228
+ });
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # resolve-diff-range.sh — the SINGLE, event-agnostic base/head SHA derivation
3
+ # shared by every diff-scoped tier of pr-quality.yml (Story #314).
4
+ #
5
+ # WHY THIS EXISTS
6
+ # ---------------
7
+ # The diff-scoped security tiers (gitleaks secret scan + Semgrep SAST) must
8
+ # scope their scan to the commits the triggering event INTRODUCED, so a
9
+ # pre-existing finding never blocks. Each event exposes that base/head pair
10
+ # under a different context path:
11
+ #
12
+ # pull_request → github.event.pull_request.base.sha / .head.sha
13
+ # merge_group → github.event.merge_group.base_sha / .head_sha
14
+ # push → github.event.before / github.sha
15
+ #
16
+ # Under a `merge_group` (merge-queue) event `github.event.pull_request.*` is
17
+ # empty, so without this derivation the tiers degrade to a FULL-TREE scan and
18
+ # surface pre-existing findings unrelated to the queued commits — bouncing the
19
+ # whole queue batch. Deriving base/head from the merge_group context instead
20
+ # keeps the queue diff-scoped. This file is the one place that classification
21
+ # lives, so gitleaks and SAST cannot drift apart.
22
+ #
23
+ # CONTRACT
24
+ # --------
25
+ # SOURCE this file (do NOT exec it) from a `shell: bash` step, after the repo
26
+ # has been checked out with `fetch-depth: 0`. It reads the env vars below (all
27
+ # optional; an absent context evaluates to the empty string in a GitHub
28
+ # expression, so nothing here can `startup_failure` on a missing context) and
29
+ # sets three variables in the CALLER's shell:
30
+ #
31
+ # Inputs (wire each to the matching context in the step's `env:` block):
32
+ # PR_BASE_SHA = github.event.pull_request.base.sha
33
+ # PR_HEAD_SHA = github.event.pull_request.head.sha
34
+ # MERGE_GROUP_BASE_SHA = github.event.merge_group.base_sha
35
+ # MERGE_GROUP_HEAD_SHA = github.event.merge_group.head_sha
36
+ # EVENT_NAME = github.event_name
37
+ # PUSH_BEFORE_SHA = github.event.before
38
+ # PUSH_HEAD_SHA = github.sha
39
+ #
40
+ # Outputs (set on the caller's shell):
41
+ # RESOLVED_EVENT_MODE = pull_request | merge_group | push | none
42
+ # RESOLVED_BASE_SHA = <base commit> (empty when mode = none)
43
+ # RESOLVED_HEAD_SHA = <head commit> (empty when mode = none)
44
+ #
45
+ # Each consumer applies its own shaping to the raw pair: gitleaks builds a
46
+ # `base..head` git-log range (`..` already excludes base-branch drift, so no
47
+ # explicit merge-base is needed); SAST needs a single `--baseline-commit`, and
48
+ # for pull_request derives the merge base of base..head (M8: base.sha is the
49
+ # live base-branch tip and drifts past the fork point once main advances),
50
+ # while for merge_group / push the base is already the exact fork point.
51
+ #
52
+ # `mode = none` means "no ranged base is resolvable" → the consumer falls back
53
+ # to a full-tree scan. This is reached by an absent context (queue no-op, or a
54
+ # branch-creation push whose `before` is the zero SHA / an unreachable commit).
55
+
56
+ resolve_diff_range() {
57
+ local zero_sha="0000000000000000000000000000000000000000"
58
+ RESOLVED_EVENT_MODE="none"
59
+ RESOLVED_BASE_SHA=""
60
+ RESOLVED_HEAD_SHA=""
61
+
62
+ if [ -n "${PR_BASE_SHA:-}" ] && [ -n "${PR_HEAD_SHA:-}" ]; then
63
+ # pull_request — the base-branch tip / PR head. Highest precedence so a
64
+ # caller that (unusually) exposes both PR and push context stays PR-scoped.
65
+ RESOLVED_EVENT_MODE="pull_request"
66
+ RESOLVED_BASE_SHA="${PR_BASE_SHA}"
67
+ RESOLVED_HEAD_SHA="${PR_HEAD_SHA}"
68
+ elif [ -n "${MERGE_GROUP_BASE_SHA:-}" ] && [ -n "${MERGE_GROUP_HEAD_SHA:-}" ]; then
69
+ # merge_group — the merge queue built base_sha..head_sha; head_sha is the
70
+ # temporary merge commit of the queued PR(s) on top of base_sha (the exact
71
+ # fork point, so no drift to correct for).
72
+ RESOLVED_EVENT_MODE="merge_group"
73
+ RESOLVED_BASE_SHA="${MERGE_GROUP_BASE_SHA}"
74
+ RESOLVED_HEAD_SHA="${MERGE_GROUP_HEAD_SHA}"
75
+ elif [ "${EVENT_NAME:-}" = "push" ] && [ -n "${PUSH_BEFORE_SHA:-}" ] && \
76
+ [ "${PUSH_BEFORE_SHA}" != "${zero_sha}" ] && \
77
+ git cat-file -e "${PUSH_BEFORE_SHA}^{commit}" 2>/dev/null; then
78
+ # push — the commits this push added (before..sha). `before` is the zero
79
+ # SHA on a branch-creation push, and may be unreachable after a
80
+ # force-push / shallow fetch; either case falls through to `none`.
81
+ RESOLVED_EVENT_MODE="push"
82
+ RESOLVED_BASE_SHA="${PUSH_BEFORE_SHA}"
83
+ RESOLVED_HEAD_SHA="${PUSH_HEAD_SHA}"
84
+ fi
85
+ }
86
+
87
+ resolve_diff_range
88
+
89
+ # When EXECUTED directly (not sourced) — e.g. by the unit test — echo the
90
+ # resolution as `KEY=value` lines so the truth table is assertable without a
91
+ # GitHub runner. `BASH_SOURCE[0] == $0` iff the file was run, not sourced.
92
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
93
+ printf 'RESOLVED_EVENT_MODE=%s\n' "${RESOLVED_EVENT_MODE}"
94
+ printf 'RESOLVED_BASE_SHA=%s\n' "${RESOLVED_BASE_SHA}"
95
+ printf 'RESOLVED_HEAD_SHA=%s\n' "${RESOLVED_HEAD_SHA}"
96
+ fi
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * resolve-diff-range.test.mjs — node:test suite for the single, event-agnostic
4
+ * base/head SHA derivation shared by every diff-scoped tier of pr-quality.yml
5
+ * (Story #314).
6
+ *
7
+ * The workflow's gitleaks and SAST resolvers both `source` this shell script,
8
+ * so its truth table IS the derived range each tier scans. This suite executes
9
+ * the script directly (it echoes `RESOLVED_*=…` lines when run rather than
10
+ * sourced) with per-event env fixtures and asserts the derived base/head for
11
+ * each of the three events plus the full-tree fallbacks — the "self-test
12
+ * showing the derived range for each event" half of the Story's Verify.
13
+ *
14
+ * Run: node scripts/resolve-diff-range.test.mjs (or `node --test scripts/`)
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { execFileSync } from "node:child_process";
19
+ import { fileURLToPath } from "node:url";
20
+ import { dirname, join } from "node:path";
21
+ import { test } from "node:test";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const SCRIPT = join(HERE, "resolve-diff-range.sh");
25
+ // The repo root is a git repo, so the push-mode reachability guard
26
+ // (`git cat-file -e <before>`) can resolve a real ancestor commit.
27
+ const REPO_ROOT = join(HERE, "..");
28
+
29
+ // A real, reachable commit for exercising the push reachability guard, and its
30
+ // parent (also reachable) to stand in as the "before" SHA.
31
+ const HEAD_SHA = execFileSync("git", ["rev-parse", "HEAD"], {
32
+ cwd: REPO_ROOT,
33
+ encoding: "utf8",
34
+ }).trim();
35
+ const PARENT_SHA = execFileSync("git", ["rev-parse", "HEAD~1"], {
36
+ cwd: REPO_ROOT,
37
+ encoding: "utf8",
38
+ }).trim();
39
+
40
+ const ZERO_SHA = "0000000000000000000000000000000000000000";
41
+ // A syntactically-valid 40-hex SHA that is not an object in this repo.
42
+ const UNREACHABLE_SHA = "dead0000dead0000dead0000dead0000dead0000";
43
+
44
+ // Run the derivation with the given env and parse its `KEY=value` output.
45
+ function resolve(env) {
46
+ const out = execFileSync("bash", [SCRIPT], {
47
+ cwd: REPO_ROOT,
48
+ encoding: "utf8",
49
+ // Start from a clean slate so the ambient CI env (which may itself set
50
+ // GITHUB_* / EVENT_NAME) cannot leak into the fixture.
51
+ env: { PATH: process.env.PATH, ...env },
52
+ });
53
+ const parsed = {};
54
+ for (const line of out.split("\n")) {
55
+ const eq = line.indexOf("=");
56
+ if (eq === -1) continue;
57
+ parsed[line.slice(0, eq)] = line.slice(eq + 1);
58
+ }
59
+ return parsed;
60
+ }
61
+
62
+ test("pull_request: derives base.sha/head.sha directly", () => {
63
+ const r = resolve({
64
+ PR_BASE_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
65
+ PR_HEAD_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
66
+ EVENT_NAME: "pull_request",
67
+ });
68
+ assert.equal(r.RESOLVED_EVENT_MODE, "pull_request");
69
+ assert.equal(r.RESOLVED_BASE_SHA, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
70
+ assert.equal(r.RESOLVED_HEAD_SHA, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
71
+ });
72
+
73
+ test("merge_group: derives merge_group.base_sha/head_sha", () => {
74
+ const r = resolve({
75
+ MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
76
+ MERGE_GROUP_HEAD_SHA: "dddddddddddddddddddddddddddddddddddddddd",
77
+ EVENT_NAME: "merge_group",
78
+ });
79
+ assert.equal(r.RESOLVED_EVENT_MODE, "merge_group");
80
+ assert.equal(r.RESOLVED_BASE_SHA, "cccccccccccccccccccccccccccccccccccccccc");
81
+ assert.equal(r.RESOLVED_HEAD_SHA, "dddddddddddddddddddddddddddddddddddddddd");
82
+ });
83
+
84
+ test("pull_request takes precedence over a co-present merge_group context", () => {
85
+ const r = resolve({
86
+ PR_BASE_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
87
+ PR_HEAD_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
88
+ MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
89
+ MERGE_GROUP_HEAD_SHA: "dddddddddddddddddddddddddddddddddddddddd",
90
+ });
91
+ assert.equal(r.RESOLVED_EVENT_MODE, "pull_request");
92
+ assert.equal(r.RESOLVED_BASE_SHA, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
93
+ });
94
+
95
+ test("merge_group takes precedence over a co-present push context", () => {
96
+ const r = resolve({
97
+ MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
98
+ MERGE_GROUP_HEAD_SHA: "dddddddddddddddddddddddddddddddddddddddd",
99
+ EVENT_NAME: "push",
100
+ PUSH_BEFORE_SHA: PARENT_SHA,
101
+ PUSH_HEAD_SHA: HEAD_SHA,
102
+ });
103
+ assert.equal(r.RESOLVED_EVENT_MODE, "merge_group");
104
+ assert.equal(r.RESOLVED_BASE_SHA, "cccccccccccccccccccccccccccccccccccccccc");
105
+ });
106
+
107
+ test("push: derives event.before/sha when before is a reachable commit", () => {
108
+ const r = resolve({
109
+ EVENT_NAME: "push",
110
+ PUSH_BEFORE_SHA: PARENT_SHA,
111
+ PUSH_HEAD_SHA: HEAD_SHA,
112
+ });
113
+ assert.equal(r.RESOLVED_EVENT_MODE, "push");
114
+ assert.equal(r.RESOLVED_BASE_SHA, PARENT_SHA);
115
+ assert.equal(r.RESOLVED_HEAD_SHA, HEAD_SHA);
116
+ });
117
+
118
+ test("push with zero before (branch creation) → none/full-tree", () => {
119
+ const r = resolve({
120
+ EVENT_NAME: "push",
121
+ PUSH_BEFORE_SHA: ZERO_SHA,
122
+ PUSH_HEAD_SHA: HEAD_SHA,
123
+ });
124
+ assert.equal(r.RESOLVED_EVENT_MODE, "none");
125
+ assert.equal(r.RESOLVED_BASE_SHA, "");
126
+ assert.equal(r.RESOLVED_HEAD_SHA, "");
127
+ });
128
+
129
+ test("push with an unreachable before (force-push/shallow) → none/full-tree", () => {
130
+ const r = resolve({
131
+ EVENT_NAME: "push",
132
+ PUSH_BEFORE_SHA: UNREACHABLE_SHA,
133
+ PUSH_HEAD_SHA: HEAD_SHA,
134
+ });
135
+ assert.equal(r.RESOLVED_EVENT_MODE, "none");
136
+ });
137
+
138
+ test("no event context at all → none/full-tree (never a startup_failure)", () => {
139
+ const r = resolve({});
140
+ assert.equal(r.RESOLVED_EVENT_MODE, "none");
141
+ assert.equal(r.RESOLVED_BASE_SHA, "");
142
+ assert.equal(r.RESOLVED_HEAD_SHA, "");
143
+ });
144
+
145
+ test("merge_group with only base_sha (partial context) → none", () => {
146
+ const r = resolve({
147
+ MERGE_GROUP_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc",
148
+ EVENT_NAME: "merge_group",
149
+ });
150
+ assert.equal(r.RESOLVED_EVENT_MODE, "none");
151
+ });
@@ -1,5 +1,6 @@
1
1
  {
2
- "$comment": "Data-driven expected roster for scripts/check-runner-health.mjs (Story #258). Each entry is one repo whose self-hosted runner fleet the scheduled runner-fleet-health.yml workflow monitors. Adding/removing a runner needs only an edit here — the checker reads GET /repos/{owner}/{repo}/actions/runners and compares live status against `expectedCount` + `labels`. All nine runners (including Beestera/swarm-os's three) are co-resident on one operator Mac (2026-07-03 runner audit, repo-ops matrix §1a); if that host sleeps, reboots, fills its disk, or a launchd service dies, every listed repo's CI silently queues with no alert until this monitor catches it.",
2
+ "$comment": "Data-driven expected roster for scripts/check-runner-health.mjs (Story #258). Each entry is one repo whose self-hosted runner fleet the scheduled runner-fleet-health.yml workflow monitors. Adding/removing a runner needs only an edit here — the checker reads GET /repos/{owner}/{repo}/actions/runners and compares live status against `expectedCount` + `labels`. The co-resident operator Mac now carries 7 domio + 5 athportal monitored runners (plus 17 beestera/swarm-os installs that are deliberately NOT listed see `$comment_swarm_os`); if that host sleeps, reboots, fills its disk, or a launchd service dies, every listed repo's CI silently queues with no alert until this monitor catches it. Keep these counts in lockstep with the live fleets: the maintenance contract lives in templates/runbooks/runner-provisioning.md.",
3
+ "$comment_expectedCount": "Semantics: warn-below (over-provisioning is fine). The checker computes shortfall = max(0, expectedCount - matchingOnline) and only alarms when fewer online runners match the labels than expected — adding runners above expectedCount never trips the alarm, so a deliberate scale-up needs no immediate roster edit (though you should still bump the count to keep the shortfall floor meaningful). Removing runners on purpose (scale-down) DOES require lowering expectedCount here, otherwise the monitor will correctly flag the missing runners as a shortfall.",
3
4
  "$comment_swarm_os": "Beestera/swarm-os is deliberately NOT listed: no dsj1984-owned PAT can read another org's runner API (fine-grained PATs are bound to one resource owner; the Beestera org rejects classic PATs), so its row would permanently false-positive as 0/3 degraded. Host-level coverage is retained regardless — its runners share the Mac with the rows below, so a wedged host still trips domio/athportal. What is NOT covered: swarm-os's individual launchd services dying while the host stays healthy, and its stale-queue check. Re-add the entry if a Beestera-owned credential (fine-grained PAT or GitHub App) plus per-repo token support ever lands.",
4
5
  "$comment_staleQueuedMinutes": "A queued/waiting workflow run older than this many minutes with no online runner matching its labels is flagged as a queue-staleness signal (optional per-repo override via `staleQueuedMinutes`).",
5
6
  "defaultStaleQueuedMinutes": 20,
@@ -7,13 +8,13 @@
7
8
  {
8
9
  "name": "domio",
9
10
  "repo": "dsj1984/domio",
10
- "expectedCount": 3,
11
+ "expectedCount": 7,
11
12
  "labels": ["self-hosted", "macOS", "ARM64", "domio-runner"]
12
13
  },
13
14
  {
14
15
  "name": "athportal",
15
16
  "repo": "dsj1984/athportal",
16
- "expectedCount": 3,
17
+ "expectedCount": 5,
17
18
  "labels": ["self-hosted", "macOS", "ARM64", "athportal-runner"]
18
19
  }
19
20
  ]
@@ -66,6 +66,28 @@ shasum -a 256 actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz
66
66
  tar xzf actions-runner-osx-arm64-<RUNNER_VERSION>.tar.gz
67
67
  ```
68
68
 
69
+ ## ⚠️ Read second: keep the health-monitor roster in lockstep
70
+
71
+ Any change to a fleet's size — **adding or removing a runner** — **MUST** be
72
+ accompanied by an update to that repo's `expectedCount` in
73
+ [`scripts/runner-fleet-consumers.json`](../../scripts/runner-fleet-consumers.json).
74
+ That file is the roster the scheduled `runner-fleet-health.yml` monitor
75
+ (`scripts/check-runner-health.mjs`) compares the live fleet against.
76
+
77
+ - **Scale up (add a runner):** bump `expectedCount` so the shortfall floor
78
+ keeps pace. Over-provisioning does not trip the alarm on its own (the
79
+ monitor is **warn-below**: `shortfall = max(0, expectedCount - matchingOnline)`),
80
+ but leaving the count stale hides a later drop back down to the old value.
81
+ - **Scale down (remove a runner):** lower `expectedCount` in the same change,
82
+ otherwise the monitor will correctly flag the now-missing runner(s) as a
83
+ shortfall and page the operator for a deliberate downsizing.
84
+
85
+ If the roster and the live fleet drift apart, the monitor either false-alarms
86
+ (count too high) or goes silent on real outages above the stale threshold
87
+ (count too low) — the exact mis-calibration this contract exists to prevent.
88
+ See also [`runner-fleet-health.md`](runner-fleet-health.md) for the operator
89
+ response when the monitor does fire.
90
+
69
91
  ## 2. Register with `config.sh` (repo-level)
70
92
 
71
93
  Registration is **repo-level** (the fleet's standing model), not org-level.
@@ -179,7 +201,10 @@ The runner loads `.env` at service start — after any `.env` change, restart:
179
201
  (§5). Mint the removal token via
180
202
  `gh api -X POST repos/<OWNER>/<REPO>/actions/runners/remove-token --jq .token`.
181
203
  - **Decommission.** Same removal sequence, then delete `<RUNNER_DIR>`.
182
- Confirm the runner disappeared from *Settings → Actions → Runners*.
204
+ Confirm the runner disappeared from *Settings → Actions → Runners*, **and**
205
+ lower the repo's `expectedCount` in `scripts/runner-fleet-consumers.json`
206
+ (see the roster-lockstep callout above) so the health monitor does not flag
207
+ the intentional removal as a shortfall.
183
208
 
184
209
  ## Project-Specific Notes
185
210