mandrel-platform 1.1.0 → 1.3.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,678 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-release-type.test.mjs — node:test suite for the release-type advisory
4
+ * (Story #368).
5
+ *
6
+ * The check exists to catch ONE pair: a diff touching the surface this
7
+ * repository publishes, landed under a title whose conventional-commit type
8
+ * cuts no release. Every other combination must stay silent, so the suite
9
+ * spends as much effort proving the quiet cases as the loud one — a lint that
10
+ * cries wolf on internal edits is worse than no lint.
11
+ *
12
+ * `runCheck` is pure (title, file list, type sets and a `readFile` seam all
13
+ * passed in), so the behavioural cases need no repository and no git. The
14
+ * wiring cases read the real ci.yml, release-please-config.json and
15
+ * package.json, because "reads the type set from the release config" and
16
+ * "adds no new status context" are claims about THIS repo, not about a
17
+ * fixture.
18
+ *
19
+ * Run: node --test scripts/check-release-type.test.mjs
20
+ */
21
+
22
+ import assert from "node:assert/strict";
23
+ import { test } from "node:test";
24
+ import { execFileSync } from "node:child_process";
25
+ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { delimiter, dirname, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ import { stepByName, runScript } from "./lib/yaml-step.mjs";
31
+ import {
32
+ EXIT,
33
+ parseArgs,
34
+ parseTitleType,
35
+ loadReleaseTypes,
36
+ loadPublishedPaths,
37
+ classifyTitle,
38
+ classifyFile,
39
+ runCheck,
40
+ renderReport,
41
+ changedFiles,
42
+ runCli,
43
+ } from "./check-release-type.mjs";
44
+
45
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
46
+ const read = (p) => readFileSync(join(REPO_ROOT, p), "utf8");
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Fixtures
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** A minimal release-please config with the same hidden/visible split shape. */
53
+ const CONFIG = JSON.stringify({
54
+ packages: {
55
+ ".": {
56
+ "changelog-sections": [
57
+ { type: "feat", section: "Added" },
58
+ { type: "fix", section: "Fixed" },
59
+ { type: "refactor", section: "Changed" },
60
+ { type: "docs", section: "Documentation", hidden: true },
61
+ { type: "chore", section: "Chores", hidden: true },
62
+ { type: "ci", section: "CI/CD", hidden: true },
63
+ ],
64
+ },
65
+ },
66
+ });
67
+
68
+ const TYPES = loadReleaseTypes(CONFIG);
69
+ const PUBLISHED = ["config", "default.json", "scripts", "templates"];
70
+
71
+ const REUSABLE_WORKFLOW = [
72
+ "name: PR quality",
73
+ "on:",
74
+ " workflow_call:",
75
+ " inputs:",
76
+ " enable-lint:",
77
+ " type: boolean",
78
+ "jobs: {}",
79
+ ].join("\n");
80
+
81
+ const INTERNAL_WORKFLOW = [
82
+ "name: CI",
83
+ "on:",
84
+ " pull_request:",
85
+ " branches: [main]",
86
+ "jobs: {}",
87
+ ].join("\n");
88
+
89
+ /** A `readFile` seam over an in-memory path → body map. */
90
+ const files = (map) => (p) => (Object.hasOwn(map, p) ? map[p] : null);
91
+
92
+ const TREE = files({
93
+ ".github/workflows/pr-quality.yml": REUSABLE_WORKFLOW,
94
+ ".github/workflows/ci.yml": INTERNAL_WORKFLOW,
95
+ });
96
+
97
+ /** Drive `runCheck` with the shared fixture context. */
98
+ const check = (title, changed, readFile = TREE) =>
99
+ runCheck({ title, files: changed, types: TYPES, publishedPaths: PUBLISHED, readFile });
100
+
101
+ /** Drive `runCli`, capturing both streams. */
102
+ function cli(argv) {
103
+ const out = [];
104
+ const errs = [];
105
+ const code = runCli(argv, {
106
+ log: (s) => out.push(String(s)),
107
+ err: (s) => errs.push(String(s)),
108
+ });
109
+ return { code, out: out.join("\n"), err: errs.join("\n") };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Title parsing
114
+ // ---------------------------------------------------------------------------
115
+
116
+ test("parseTitleType reads the type, tolerating a scope and casing", () => {
117
+ assert.deepEqual(parseTitleType("feat: add a thing"), {
118
+ parsed: true,
119
+ type: "feat",
120
+ breaking: false,
121
+ });
122
+ assert.deepEqual(parseTitleType("fix(scripts): correct the range"), {
123
+ parsed: true,
124
+ type: "fix",
125
+ breaking: false,
126
+ });
127
+ assert.deepEqual(parseTitleType("CI: bump the runner"), {
128
+ parsed: true,
129
+ type: "ci",
130
+ breaking: false,
131
+ });
132
+ });
133
+
134
+ test("parseTitleType flags the `!` breaking marker in both positions", () => {
135
+ assert.equal(parseTitleType("ci!: drop an input").breaking, true);
136
+ assert.equal(parseTitleType("ci(workflows)!: drop an input").breaking, true);
137
+ });
138
+
139
+ test("parseTitleType refuses a subject that is not conventional-commit shaped", () => {
140
+ for (const title of ["", "just some words", "feat add a thing", "feat:", "feat: "]) {
141
+ assert.equal(parseTitleType(title).parsed, false, `expected unparseable: ${title}`);
142
+ }
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // AC-4 — the type set comes from the release configuration
147
+ // ---------------------------------------------------------------------------
148
+
149
+ test("AC-4: the hidden/releasable split is read from the release config", () => {
150
+ const types = loadReleaseTypes(CONFIG);
151
+ assert.deepEqual([...types.releasable].sort(), ["feat", "fix", "refactor"]);
152
+ assert.deepEqual([...types.hidden].sort(), ["chore", "ci", "docs"]);
153
+ });
154
+
155
+ test("AC-4: flipping `hidden` in the config flips the verdict — nothing is duplicated in the check", () => {
156
+ const inverted = JSON.stringify({
157
+ packages: {
158
+ ".": {
159
+ "changelog-sections": [
160
+ { type: "feat", section: "Added", hidden: true },
161
+ { type: "ci", section: "CI/CD" },
162
+ ],
163
+ },
164
+ },
165
+ });
166
+ const types = loadReleaseTypes(inverted);
167
+ assert.equal(classifyTitle("feat: a thing", types).releasable, false);
168
+ assert.equal(classifyTitle("ci: a thing", types).releasable, true);
169
+ });
170
+
171
+ test("AC-4: this repo's real release config parses, and hides `ci` while releasing `feat`", () => {
172
+ const types = loadReleaseTypes(read("release-please-config.json"));
173
+ assert.notEqual(types, null);
174
+ assert.equal(types.hidden.has("ci"), true);
175
+ assert.equal(types.releasable.has("feat"), true);
176
+ assert.equal(types.releasable.has("fix"), true);
177
+ });
178
+
179
+ test("loadReleaseTypes returns null when the config declares no sections", () => {
180
+ assert.equal(loadReleaseTypes("{}"), null);
181
+ assert.equal(loadReleaseTypes("not json"), null);
182
+ });
183
+
184
+ test("a type visible in any package is releasable, and never also reported hidden", () => {
185
+ const multi = JSON.stringify({
186
+ packages: {
187
+ a: { "changelog-sections": [{ type: "perf", section: "Perf", hidden: true }] },
188
+ b: { "changelog-sections": [{ type: "perf", section: "Perf" }] },
189
+ },
190
+ });
191
+ const types = loadReleaseTypes(multi);
192
+ assert.equal(types.releasable.has("perf"), true);
193
+ assert.equal(types.hidden.has("perf"), false);
194
+ });
195
+
196
+ test("classifyTitle: a breaking marker releases even on a hidden type", () => {
197
+ const verdict = classifyTitle("ci!: drop a workflow input", TYPES);
198
+ assert.equal(verdict.releasable, true);
199
+ assert.equal(verdict.titleClass, "breaking");
200
+ });
201
+
202
+ test("classifyTitle: a type with no configured section cannot release", () => {
203
+ const verdict = classifyTitle("wip: half a thing", TYPES);
204
+ assert.equal(verdict.releasable, false);
205
+ assert.equal(verdict.titleClass, "unknown-type");
206
+ });
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Surface classification
210
+ // ---------------------------------------------------------------------------
211
+
212
+ test("a workflow declaring workflow_call is consumer-facing; a standing check is not", () => {
213
+ assert.deepEqual(
214
+ classifyFile(".github/workflows/pr-quality.yml", { publishedPaths: PUBLISHED, readFile: TREE }),
215
+ { consumerFacing: true, surface: "reusable workflow" }
216
+ );
217
+ assert.deepEqual(
218
+ classifyFile(".github/workflows/ci.yml", { publishedPaths: PUBLISHED, readFile: TREE }),
219
+ { consumerFacing: false, surface: null }
220
+ );
221
+ });
222
+
223
+ test("a workflow that cannot be read is treated as consumer-facing (the deleted case)", () => {
224
+ const c = classifyFile(".github/workflows/gone.yml", {
225
+ publishedPaths: PUBLISHED,
226
+ readFile: TREE,
227
+ });
228
+ assert.equal(c.consumerFacing, true);
229
+ assert.match(c.surface, /deleted or unreadable/);
230
+ });
231
+
232
+ test("composite actions and published package files are consumer-facing", () => {
233
+ for (const [path, surface] of [
234
+ [".github/actions/setup-toolchain/action.yml", "composite action"],
235
+ ["config/biome.base.json", "published package file"],
236
+ ["config/edge-security/rate-limit.mjs", "published package file"],
237
+ ["default.json", "published package file"],
238
+ ["scripts/check-action-pins.mjs", "published package file"],
239
+ ["templates/runbooks/deploy-promotion.md", "published package file"],
240
+ ]) {
241
+ const c = classifyFile(path, { publishedPaths: PUBLISHED, readFile: TREE });
242
+ assert.equal(c.consumerFacing, true, `expected consumer-facing: ${path}`);
243
+ assert.equal(c.surface, surface);
244
+ }
245
+ });
246
+
247
+ test("internal tooling, tests, docs and package.json are not consumer-facing", () => {
248
+ for (const path of [
249
+ "docs/architecture.md",
250
+ "docs/runbooks/main-protection.json",
251
+ "README.md",
252
+ "CHANGELOG.md",
253
+ "package.json",
254
+ "package-lock.json",
255
+ ".agents/instructions.md",
256
+ ".github/ISSUE_TEMPLATE/story.yml",
257
+ "scripts/check-action-pins.test.mjs",
258
+ "config/edge-security/rate-limit.test.mjs",
259
+ ]) {
260
+ assert.deepEqual(
261
+ classifyFile(path, { publishedPaths: PUBLISHED, readFile: TREE }),
262
+ { consumerFacing: false, surface: null },
263
+ `expected internal: ${path}`
264
+ );
265
+ }
266
+ });
267
+
268
+ test("loadPublishedPaths normalises the npm files allowlist and drops glob entries", () => {
269
+ assert.deepEqual(
270
+ loadPublishedPaths(JSON.stringify({ files: ["config/", "./default.json", "dist/**", ""] })),
271
+ ["config", "default.json"]
272
+ );
273
+ assert.deepEqual(loadPublishedPaths("{}"), []);
274
+ assert.deepEqual(loadPublishedPaths("nonsense"), []);
275
+ });
276
+
277
+ test("this repo's real package.json publishes config/, default.json, scripts/ and templates/", () => {
278
+ assert.deepEqual(loadPublishedPaths(read("package.json")).sort(), [
279
+ "config",
280
+ "default.json",
281
+ "scripts",
282
+ "templates",
283
+ ]);
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // AC-1 / AC-2 / AC-3 — the pair, and only the pair
288
+ // ---------------------------------------------------------------------------
289
+
290
+ test("AC-1: a consumer-facing change under a hidden type is reported with type and surface", () => {
291
+ const result = check("ci: add a workflow input", [
292
+ ".github/workflows/pr-quality.yml",
293
+ "docs/architecture.md",
294
+ ]);
295
+ assert.equal(result.status, "mismatch");
296
+ assert.equal(result.type, "ci");
297
+ assert.equal(result.titleClass, "hidden");
298
+ assert.deepEqual(result.surfaces, [
299
+ { file: ".github/workflows/pr-quality.yml", surface: "reusable workflow" },
300
+ ]);
301
+ // The internal file rode along but is not part of the finding.
302
+ assert.equal(result.surfaces.some((s) => s.file === "docs/architecture.md"), false);
303
+ });
304
+
305
+ test("AC-1: an unparseable title over a consumer-facing change is reported too", () => {
306
+ const result = check("bump the pins", ["config/renovate.json"]);
307
+ assert.equal(result.status, "mismatch");
308
+ assert.equal(result.type, null);
309
+ assert.equal(result.titleClass, "unparseable");
310
+ });
311
+
312
+ test("AC-1: the report names both the title type and the surface it touched", () => {
313
+ const result = check("ci: add a workflow input", [
314
+ ".github/workflows/pr-quality.yml",
315
+ "config/biome.base.json",
316
+ ]);
317
+ const { lines, annotation, summary } = renderReport(result);
318
+ const text = lines.join("\n");
319
+
320
+ // The title type, by name.
321
+ assert.match(text, /`ci`/);
322
+ assert.match(text, /hidden: true/);
323
+ // Each surface, by path AND by what kind of surface it is.
324
+ assert.match(text, /\.github\/workflows\/pr-quality\.yml \[reusable workflow\]/);
325
+ assert.match(text, /config\/biome\.base\.json \[published package file\]/);
326
+
327
+ // The annotation is one line — GitHub truncates a workflow command at the
328
+ // first real newline, which would silently drop every path but the first.
329
+ assert.equal(annotation.includes("\n"), false);
330
+ assert.match(annotation, /^::warning title=/);
331
+ assert.match(annotation, /pr-quality\.yml/);
332
+ assert.match(annotation, /biome\.base\.json/);
333
+
334
+ assert.match(summary, /pr-quality\.yml/);
335
+ assert.match(summary, /never fails the pull request/);
336
+ });
337
+
338
+ test("AC-2: the same change under a releasing title passes silently", () => {
339
+ for (const title of ["feat: add a workflow input", "fix: correct a workflow input"]) {
340
+ const result = check(title, [".github/workflows/pr-quality.yml", "config/biome.base.json"]);
341
+ assert.equal(result.status, "ok", title);
342
+ }
343
+ });
344
+
345
+ test("AC-3: a change confined to internal tooling, tests or docs stays quiet under a hidden type", () => {
346
+ const result = check("ci: retune the aggregator", [
347
+ ".github/workflows/ci.yml",
348
+ "scripts/check-action-pins.test.mjs",
349
+ "docs/architecture.md",
350
+ "README.md",
351
+ "package.json",
352
+ ]);
353
+ assert.equal(result.status, "ok");
354
+ assert.deepEqual(result.surfaces, []);
355
+ });
356
+
357
+ test("AC-3: release-please's own release pull request does not trip the check", () => {
358
+ const result = check("chore(main): release mandrel-platform 1.2.0", [
359
+ "package.json",
360
+ "CHANGELOG.md",
361
+ ".release-please-manifest.json",
362
+ ]);
363
+ assert.equal(result.status, "ok");
364
+ });
365
+
366
+ test("a missing title, an unanswerable config, or an unresolvable diff all skip", () => {
367
+ assert.equal(check("", [".github/workflows/pr-quality.yml"]).status, "skipped");
368
+ assert.equal(
369
+ runCheck({ title: "ci: x", files: ["config/a.json"], types: null }).status,
370
+ "skipped"
371
+ );
372
+ assert.equal(
373
+ runCheck({ title: "ci: x", files: null, types: TYPES }).status,
374
+ "skipped"
375
+ );
376
+ });
377
+
378
+ test("changedFiles answers null rather than throwing on an unresolvable range", () => {
379
+ assert.equal(changedFiles(REPO_ROOT, "", ""), null);
380
+ assert.equal(changedFiles(REPO_ROOT, "definitely-not-a-ref", "HEAD"), null);
381
+ });
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // AC-6 — reports, never blocks
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Build a throwaway repository whose HEAD commit edits `changed`, so the CLI
389
+ * can be driven over a REAL diff range — the only way to cover the
390
+ * argv → git → classify → render path end to end.
391
+ */
392
+ function fixtureRepo(changed) {
393
+ const root = mkdtempSync(join(tmpdir(), "release-type-"));
394
+ const git = (...args) =>
395
+ execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
396
+ const put = (rel, body) => {
397
+ mkdirSync(join(root, rel, ".."), { recursive: true });
398
+ writeFileSync(join(root, rel), body, "utf8");
399
+ };
400
+
401
+ git("init", "-q", "-b", "main");
402
+ git("config", "user.email", "t@example.com");
403
+ git("config", "user.name", "T");
404
+ git("config", "commit.gpgsign", "false");
405
+
406
+ put("release-please-config.json", CONFIG);
407
+ put("package.json", JSON.stringify({ files: ["config/", "scripts/"] }));
408
+ put(".github/workflows/pr-quality.yml", REUSABLE_WORKFLOW);
409
+ put(".github/workflows/ci.yml", INTERNAL_WORKFLOW);
410
+ put("docs/architecture.md", "# base\n");
411
+ put("config/biome.base.json", "{}\n");
412
+ git("add", "-A");
413
+ git("commit", "-qm", "base");
414
+ const base = git("rev-parse", "HEAD").trim();
415
+
416
+ for (const [rel, body] of Object.entries(changed)) put(rel, body);
417
+ git("add", "-A");
418
+ git("commit", "-qm", "change");
419
+ const head = git("rev-parse", "HEAD").trim();
420
+
421
+ return { root, base, head, cleanup: () => rmSync(root, { recursive: true, force: true }) };
422
+ }
423
+
424
+ test("AC-6: end to end, a real mismatch exits 0 and reports rather than failing", () => {
425
+ const repo = fixtureRepo({
426
+ ".github/workflows/pr-quality.yml": `${REUSABLE_WORKFLOW}\n# edited\n`,
427
+ "docs/architecture.md": "# edited\n",
428
+ });
429
+ try {
430
+ const { code, out, err } = cli([
431
+ "--cwd",
432
+ repo.root,
433
+ "--base",
434
+ repo.base,
435
+ "--head",
436
+ repo.head,
437
+ "--title",
438
+ "ci: add a workflow input",
439
+ ]);
440
+ assert.equal(code, EXIT.mismatch, "a finding is reported as its own exit code");
441
+ assert.match(err, /⚠️/);
442
+ assert.match(err, /`ci`/);
443
+ assert.match(err, /pr-quality\.yml \[reusable workflow\]/);
444
+ assert.match(out, /^::warning title=/m);
445
+ // The internal file in the same diff is not part of the finding.
446
+ assert.equal(/architecture\.md/.test(err), false);
447
+ assert.equal(/❌/.test(out) || /❌/.test(err), false);
448
+ } finally {
449
+ repo.cleanup();
450
+ }
451
+ });
452
+
453
+ test("AC-2: end to end, the same diff under `feat:` reports nothing", () => {
454
+ const repo = fixtureRepo({
455
+ ".github/workflows/pr-quality.yml": `${REUSABLE_WORKFLOW}\n# edited\n`,
456
+ });
457
+ try {
458
+ const { code, out, err } = cli([
459
+ "--cwd",
460
+ repo.root,
461
+ "--base",
462
+ repo.base,
463
+ "--head",
464
+ repo.head,
465
+ "--title",
466
+ "feat: add a workflow input",
467
+ ]);
468
+ assert.equal(code, 0);
469
+ assert.equal(err, "");
470
+ assert.equal(out.includes("::warning"), false);
471
+ assert.equal(out.includes("⚠️"), false);
472
+ } finally {
473
+ repo.cleanup();
474
+ }
475
+ });
476
+
477
+ test("AC-3: end to end, an internal-only diff under `ci:` reports nothing", () => {
478
+ const repo = fixtureRepo({
479
+ ".github/workflows/ci.yml": `${INTERNAL_WORKFLOW}\n# edited\n`,
480
+ "docs/architecture.md": "# edited\n",
481
+ });
482
+ try {
483
+ const { code, out, err } = cli([
484
+ "--cwd",
485
+ repo.root,
486
+ "--base",
487
+ repo.base,
488
+ "--head",
489
+ repo.head,
490
+ "--title",
491
+ "ci: retune the aggregator",
492
+ ]);
493
+ assert.equal(code, 0);
494
+ assert.equal(err, "");
495
+ assert.equal(out.includes("::warning"), false);
496
+ } finally {
497
+ repo.cleanup();
498
+ }
499
+ });
500
+
501
+ test("AC-6: a push run with no pull-request title skips instead of guessing", () => {
502
+ assert.equal(cli(["--title", ""]).code, EXIT.skipped);
503
+ assert.match(cli(["--title", ""]).out, /skipped/);
504
+ });
505
+
506
+ test("an unknown flag is the usage error that means the check did not run", () => {
507
+ assert.equal(cli(["--nope"]).code, EXIT.usage);
508
+ assert.equal(parseArgs(["--title", "feat: x"]).title, "feat: x");
509
+ });
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // AC-5 (Story #377) — three outcomes, three exit codes, still not blocking
513
+ // ---------------------------------------------------------------------------
514
+
515
+ test("AC-5: ok, mismatch and skipped are three distinguishable exit codes", () => {
516
+ // The whole point: a caller can tell the three apart without parsing log
517
+ // text. Exiting 0 uniformly let this capability go permanently inert — a
518
+ // check that skips forever looked exactly like one that kept finding
519
+ // nothing.
520
+ assert.deepEqual(
521
+ [EXIT.ok, EXIT.usage, EXIT.mismatch, EXIT.skipped],
522
+ [0, 1, 2, 3],
523
+ "the exit codes are a published contract; changing one is a breaking change"
524
+ );
525
+ assert.equal(new Set(Object.values(EXIT)).size, 4, "no two outcomes share a code");
526
+ });
527
+
528
+ test("AC-5: each outcome returns its own code end to end", () => {
529
+ const mismatch = fixtureRepo({
530
+ ".github/workflows/pr-quality.yml": `${REUSABLE_WORKFLOW}\n# edited\n`,
531
+ });
532
+ try {
533
+ const range = ["--cwd", mismatch.root, "--base", mismatch.base, "--head", mismatch.head];
534
+
535
+ assert.equal(cli([...range, "--title", "ci: add a workflow input"]).code, EXIT.mismatch);
536
+ assert.equal(cli([...range, "--title", "feat: add a workflow input"]).code, EXIT.ok);
537
+ // An unresolvable range is the shallow-clone degradation: a skip, and now
538
+ // a skip that says so.
539
+ assert.equal(
540
+ cli(["--cwd", mismatch.root, "--title", "ci: add a workflow input"]).code,
541
+ EXIT.skipped
542
+ );
543
+ } finally {
544
+ mismatch.cleanup();
545
+ }
546
+ });
547
+
548
+ /**
549
+ * Execute the ci.yml step's real `run:` body against a stub `node` that exits
550
+ * `stubCode`, and return what the STEP exited with.
551
+ *
552
+ * Asserting the tolerance by regex would prove only that some text is present.
553
+ * The claim under test — "a mismatch cannot fail the build, a broken check
554
+ * still can" — is a property of the shell branch, so the shell branch is what
555
+ * runs here. `bash -eo pipefail` mirrors GitHub's default `run:` shell, which
556
+ * is the part most likely to break a naive `|| code=$?`.
557
+ */
558
+ function runCiStep(stubCode) {
559
+ const step = stepByName(read(".github/workflows/ci.yml"), "non-releasing title");
560
+ const body = runScript(step);
561
+
562
+ const dir = mkdtempSync(join(tmpdir(), "release-type-step-"));
563
+ try {
564
+ const stub = join(dir, "node");
565
+ writeFileSync(stub, `#!/bin/sh\nexit ${stubCode}\n`);
566
+ chmodSync(stub, 0o755);
567
+ const script = join(dir, "step.sh");
568
+ writeFileSync(script, body);
569
+
570
+ const opts = {
571
+ cwd: dir,
572
+ encoding: "utf8",
573
+ stdio: ["ignore", "pipe", "pipe"],
574
+ env: { PATH: `${dir}${delimiter}${process.env.PATH}` },
575
+ };
576
+ try {
577
+ return { code: 0, output: execFileSync("bash", ["-eo", "pipefail", script], opts) };
578
+ } catch (e) {
579
+ return { code: e.status ?? 1, output: `${e.stdout ?? ""}${e.stderr ?? ""}` };
580
+ }
581
+ } finally {
582
+ rmSync(dir, { recursive: true, force: true });
583
+ }
584
+ }
585
+
586
+ test("AC-5: the ci.yml step tolerates every advisory outcome and names which one it got", () => {
587
+ for (const [stubCode, label] of [
588
+ [EXIT.ok, /outcome: ok/],
589
+ [EXIT.mismatch, /outcome: mismatch/],
590
+ [EXIT.skipped, /outcome: skipped/],
591
+ ]) {
592
+ const { code, output } = runCiStep(stubCode);
593
+ assert.equal(code, 0, `exit ${stubCode} must not fail the step or the required aggregator`);
594
+ assert.match(output, label);
595
+ }
596
+ });
597
+
598
+ test("AC-5: a check that could not run still fails the step", () => {
599
+ // The other half, and the reason the tolerance is a `case` rather than a
600
+ // `|| true`: a usage error means the check never ran, which must not read as
601
+ // a check that ran and found nothing.
602
+ const { code, output } = runCiStep(EXIT.usage);
603
+ assert.equal(code, EXIT.usage);
604
+ assert.match(output, /the check itself failed/);
605
+ });
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // AC-5 — wired as a step in the existing job, adding no status context
609
+ // ---------------------------------------------------------------------------
610
+
611
+ /** Top-level job ids declared under `jobs:` in a workflow body. */
612
+ function jobIds(body) {
613
+ const lines = body.split("\n");
614
+ const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
615
+ assert.notEqual(start, -1, "ci.yml declares a jobs: block");
616
+ const ids = [];
617
+ for (const line of lines.slice(start + 1)) {
618
+ if (/^\S/.test(line)) break;
619
+ const m = line.match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
620
+ if (m) ids.push(m[1]);
621
+ }
622
+ return ids;
623
+ }
624
+
625
+ /** The body of one top-level job, as text. */
626
+ function jobBlock(body, jobId) {
627
+ const lines = body.split("\n");
628
+ const start = lines.findIndex((l) => l.startsWith(` ${jobId}:`));
629
+ assert.notEqual(start, -1, `ci.yml declares the ${jobId} job`);
630
+ const rest = lines.slice(start + 1);
631
+ const end = rest.findIndex((l) => /^ {2}[A-Za-z0-9_-]+:\s*$/.test(l));
632
+ return (end === -1 ? rest : rest.slice(0, end)).join("\n");
633
+ }
634
+
635
+ test("AC-5: the check runs as a step in the existing node-scripts job", () => {
636
+ const ci = read(".github/workflows/ci.yml");
637
+ const block = jobBlock(ci, "node-scripts");
638
+ assert.match(runScript(stepByName(block, "non-releasing title")), /node scripts\/check-release-type\.mjs/);
639
+ });
640
+
641
+ test("AC-4 (#377): the node-scripts job checks out full history", () => {
642
+ // `changedFiles` resolves the diff with `git diff base...head`, which needs
643
+ // the merge base in the local object store. A shallow clone makes that range
644
+ // unresolvable, the check degrades to `skipped`, and it stays green forever
645
+ // while classifying nothing — so the depth this check depends on is pinned
646
+ // here rather than left to a comment. The pin-lag guard in
647
+ // check-workflow-portability.mjs depends on the same full history.
648
+ const checkout = stepByName(jobBlock(read(".github/workflows/ci.yml"), "node-scripts"), "Checkout");
649
+
650
+ assert.match(
651
+ checkout,
652
+ /^\s+fetch-depth: 0\s*$/m,
653
+ "the node-scripts checkout must fetch full history"
654
+ );
655
+ });
656
+
657
+ test("AC-5: the step is given the pull-request title and diff range it needs", () => {
658
+ const block = jobBlock(read(".github/workflows/ci.yml"), "node-scripts");
659
+ assert.match(block, /PR_TITLE: \$\{\{ github\.event\.pull_request\.title \}\}/);
660
+ assert.match(block, /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/);
661
+ assert.match(block, /HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/);
662
+ });
663
+
664
+ test("AC-5: no new job — and therefore no new status context — is introduced", () => {
665
+ const ci = read(".github/workflows/ci.yml");
666
+ assert.deepEqual(jobIds(ci).sort(), [
667
+ "actionlint",
668
+ "ci-required",
669
+ "code-scanning",
670
+ "node-scripts",
671
+ "runner-kit-bash32",
672
+ "security",
673
+ ]);
674
+
675
+ // The branch-protection contract still names exactly one required context.
676
+ const protection = JSON.parse(read("docs/runbooks/main-protection.json"));
677
+ assert.deepEqual(protection.requiredStatusChecks, ["ci-required"]);
678
+ });