mandrel-platform 0.20.1 → 0.24.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/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -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/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/templates/runbooks/README.md +13 -0
- package/templates/runbooks/runner-fleet-health.md +150 -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-run.yml +77 -0
- package/templates/workflows/deploy-staging.yml +67 -62
package/package.json
CHANGED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-ci-required-aggregator.test.mjs — regression guard for the
|
|
4
|
+
* self-maintaining `ci-required` aggregators (Story #234).
|
|
5
|
+
*
|
|
6
|
+
* Both `ci-required` aggregator jobs — `.github/workflows/pr-quality.yml`
|
|
7
|
+
* (the fleet-wide reusable PR gate) and `.github/workflows/ci.yml` (this
|
|
8
|
+
* repo's own gate) — previously required hand-maintained triple bookkeeping:
|
|
9
|
+
* the `needs:` array, a per-job `env:` block, and a bash loop over hardcoded
|
|
10
|
+
* job names. A job added to `needs:` but forgotten in the env/loop silently
|
|
11
|
+
* passed on a red run — on the platform's sole required branch-protection
|
|
12
|
+
* context.
|
|
13
|
+
*
|
|
14
|
+
* Story #234 replaced both with a `toJSON(needs)`-driven check. This suite
|
|
15
|
+
* pins that design against regression:
|
|
16
|
+
*
|
|
17
|
+
* 1. STRUCTURE — each aggregator's `steps:` derive results from
|
|
18
|
+
* `toJSON(needs)` and contain NO hardcoded reference to any job named in
|
|
19
|
+
* its own `needs:` array (the "no hardcoded tier-name list" AC).
|
|
20
|
+
* 2. PARITY — the two aggregators' `steps:` blocks are textually identical,
|
|
21
|
+
* so a fix to one cannot drift from the other.
|
|
22
|
+
* 3. SEMANTICS — the shared run script passes on `success`/`skipped` and
|
|
23
|
+
* fails on anything else, INCLUDING `cancelled` (load-bearing for #223's
|
|
24
|
+
* fail-fast design), while naming the failing jobs and their results.
|
|
25
|
+
* Executed against real bash+jq; skipped when jq is unavailable locally
|
|
26
|
+
* (CI's ubuntu runner always has it).
|
|
27
|
+
*
|
|
28
|
+
* Run: node --test scripts/check-ci-required-aggregator.test.mjs
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { test } from "node:test";
|
|
33
|
+
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
34
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
35
|
+
import { join, resolve, dirname } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
|
|
39
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
40
|
+
|
|
41
|
+
const AGGREGATOR_FILES = [
|
|
42
|
+
".github/workflows/pr-quality.yml",
|
|
43
|
+
".github/workflows/ci.yml",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Minimal indentation-based extraction (dependency-free, mirrors the
|
|
48
|
+
// line-oriented approach of check-workflow-portability.mjs). Both workflows
|
|
49
|
+
// declare jobs at 2-space indent, so the `ci-required` job block runs from
|
|
50
|
+
// its ` ci-required:` line to the next non-blank line at indent <= 2.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function extractJobBlock(content, jobId) {
|
|
54
|
+
const lines = content.split("\n");
|
|
55
|
+
const start = lines.findIndex((l) => l === ` ${jobId}:`);
|
|
56
|
+
assert.notEqual(start, -1, `job \`${jobId}\` not found`);
|
|
57
|
+
let end = lines.length;
|
|
58
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
59
|
+
if (/^\s*$/.test(lines[i])) continue;
|
|
60
|
+
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
61
|
+
if (indent <= 2) {
|
|
62
|
+
end = i;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return lines.slice(start, end).join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The `- <name>` entries under the job's `needs:` key. */
|
|
70
|
+
function extractNeeds(jobBlock) {
|
|
71
|
+
const lines = jobBlock.split("\n");
|
|
72
|
+
const start = lines.findIndex((l) => l === " needs:");
|
|
73
|
+
assert.notEqual(start, -1, "`needs:` block not found");
|
|
74
|
+
const names = [];
|
|
75
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
76
|
+
const m = lines[i].match(/^\s+-\s+([A-Za-z0-9_-]+)\s*$/);
|
|
77
|
+
if (!m) break;
|
|
78
|
+
names.push(m[1]);
|
|
79
|
+
}
|
|
80
|
+
assert.ok(names.length > 0, "`needs:` list is empty");
|
|
81
|
+
return names;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Everything from ` steps:` to the end of the job block. */
|
|
85
|
+
function extractSteps(jobBlock) {
|
|
86
|
+
const idx = jobBlock.indexOf(" steps:");
|
|
87
|
+
assert.notEqual(idx, -1, "`steps:` block not found");
|
|
88
|
+
return jobBlock.slice(idx);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The dedented body of the (single) `run: |` block scalar in the steps. */
|
|
92
|
+
function extractRunScript(steps) {
|
|
93
|
+
const lines = steps.split("\n");
|
|
94
|
+
const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
|
|
95
|
+
assert.notEqual(start, -1, "`run: |` block not found");
|
|
96
|
+
const runIndent = lines[start].match(/^(\s*)/)[1].length;
|
|
97
|
+
const body = [];
|
|
98
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
99
|
+
if (/^\s*$/.test(lines[i])) {
|
|
100
|
+
body.push("");
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
104
|
+
if (indent <= runIndent) break;
|
|
105
|
+
body.push(lines[i].slice(runIndent + 2));
|
|
106
|
+
}
|
|
107
|
+
return body.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const blocks = AGGREGATOR_FILES.map((rel) => {
|
|
111
|
+
const content = readFileSync(join(repoRoot, rel), "utf8");
|
|
112
|
+
const job = extractJobBlock(content, "ci-required");
|
|
113
|
+
return { rel, job, needs: extractNeeds(job), steps: extractSteps(job) };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 1. STRUCTURE — toJSON(needs)-driven, no hardcoded tier-name list
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
for (const { rel, needs, steps } of blocks) {
|
|
121
|
+
test(`${rel}: aggregator derives results from toJSON(needs)`, () => {
|
|
122
|
+
assert.match(
|
|
123
|
+
steps,
|
|
124
|
+
/\$\{\{\s*toJSON\(needs\)\s*\}\}/,
|
|
125
|
+
"the aggregator steps must consume `${{ toJSON(needs) }}`"
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test(`${rel}: aggregator steps contain no hardcoded tier/job name`, () => {
|
|
130
|
+
// Strip YAML comments — prose may legitimately mention a job; only the
|
|
131
|
+
// executable env/run surface must stay name-free.
|
|
132
|
+
const executable = steps
|
|
133
|
+
.split("\n")
|
|
134
|
+
.filter((l) => !/^\s*#/.test(l))
|
|
135
|
+
.join("\n");
|
|
136
|
+
for (const name of needs) {
|
|
137
|
+
const re = new RegExp(
|
|
138
|
+
`(?<![A-Za-z0-9_-])${name.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}(?![A-Za-z0-9_-])`
|
|
139
|
+
);
|
|
140
|
+
assert.doesNotMatch(
|
|
141
|
+
executable,
|
|
142
|
+
re,
|
|
143
|
+
`steps hardcode \`${name}\` — adding a job to \`needs:\` must be the only edit; ` +
|
|
144
|
+
`never reintroduce the per-job env/loop bookkeeping`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
assert.ok(
|
|
148
|
+
!/needs\.[A-Za-z0-9_-]+\.result/.test(executable),
|
|
149
|
+
"steps must not read per-job `needs.<id>.result` expressions"
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// 2. PARITY — the two implementations are textually identical
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test("pr-quality.yml and ci.yml aggregator steps are textually identical", () => {
|
|
159
|
+
assert.equal(
|
|
160
|
+
blocks[0].steps,
|
|
161
|
+
blocks[1].steps,
|
|
162
|
+
"the two `ci-required` steps blocks must not drift — apply every change to both"
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// 3. SEMANTICS — pass on success/skipped, fail (naming jobs) on anything else
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function jqAvailable() {
|
|
171
|
+
try {
|
|
172
|
+
execFileSync("jq", ["--version"], { stdio: "ignore" });
|
|
173
|
+
return true;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function runAggregator(needsResults) {
|
|
180
|
+
const script = extractRunScript(blocks[0].steps);
|
|
181
|
+
const dir = mkdtempSync(join(tmpdir(), "ci-required-"));
|
|
182
|
+
try {
|
|
183
|
+
const file = join(dir, "aggregate.sh");
|
|
184
|
+
writeFileSync(file, script);
|
|
185
|
+
const needsJson = Object.fromEntries(
|
|
186
|
+
Object.entries(needsResults).map(([k, result]) => [k, { result, outputs: {} }])
|
|
187
|
+
);
|
|
188
|
+
return spawnSync("bash", [file], {
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
env: { ...process.env, NEEDS_JSON: JSON.stringify(needsJson) },
|
|
191
|
+
});
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const semantics = { skip: jqAvailable() ? false : "jq not available on this host" };
|
|
198
|
+
|
|
199
|
+
test("run script: all success → exit 0", semantics, () => {
|
|
200
|
+
const r = runAggregator({ lint: "success", unit: "success" });
|
|
201
|
+
assert.equal(r.status, 0, r.stderr);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("run script: skipped counts as a pass", semantics, () => {
|
|
205
|
+
const r = runAggregator({ lint: "success", e2e: "skipped" });
|
|
206
|
+
assert.equal(r.status, 0, r.stderr);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("run script: failure fails and names the job(result)", semantics, () => {
|
|
210
|
+
const r = runAggregator({ lint: "success", unit: "failure" });
|
|
211
|
+
assert.equal(r.status, 1);
|
|
212
|
+
assert.match(r.stderr, /unit\(failure\)/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("run script: cancelled fails (load-bearing for fail-fast, #223)", semantics, () => {
|
|
216
|
+
const r = runAggregator({ lint: "success", e2e: "cancelled" });
|
|
217
|
+
assert.equal(r.status, 1);
|
|
218
|
+
assert.match(r.stderr, /e2e\(cancelled\)/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("run script: every non-passing job is named", semantics, () => {
|
|
222
|
+
const r = runAggregator({ lint: "failure", unit: "cancelled", e2e: "success" });
|
|
223
|
+
assert.equal(r.status, 1);
|
|
224
|
+
assert.match(r.stderr, /lint\(failure\)/);
|
|
225
|
+
assert.match(r.stderr, /unit\(cancelled\)/);
|
|
226
|
+
assert.doesNotMatch(r.stderr, /e2e/);
|
|
227
|
+
});
|
|
@@ -481,63 +481,46 @@ test("formatVerdict renders a skip line for the disabled gate", () => {
|
|
|
481
481
|
});
|
|
482
482
|
|
|
483
483
|
// ---------------------------------------------------------------------------
|
|
484
|
-
// pr-quality.yml Coverage threshold gate —
|
|
484
|
+
// pr-quality.yml Coverage threshold gate — workflow-step parity (#163, #230)
|
|
485
485
|
//
|
|
486
|
-
// The workflow
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
486
|
+
// The workflow's "Coverage threshold gate" step no longer embeds a copy of
|
|
487
|
+
// this script (Story #230): it sparse-side-checkouts mandrel-platform at
|
|
488
|
+
// `github.job_workflow_sha` into `_mandrel-platform-scripts/` and runs
|
|
489
|
+
// `scripts/check-coverage-threshold.mjs` directly. These tests exercise that
|
|
490
|
+
// exact invocation shape — the real script, run from the side-checkout path,
|
|
491
|
+
// with the workflow's `--threshold` / `--metric` args and the consumer
|
|
492
|
+
// checkout as cwd — against real fixture trees, so the original #163 bug
|
|
493
|
+
// (matching a directory literally named `coverage`, missing
|
|
494
|
+
// `coverage/<workspace>/coverage-summary.json`) still cannot regress, and a
|
|
495
|
+
// drift between "what the workflow runs" and "what these tests run" is
|
|
496
|
+
// structurally impossible.
|
|
494
497
|
// ---------------------------------------------------------------------------
|
|
495
498
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
// scalar so it runs as a standalone ES module.
|
|
499
|
-
function extractGateScript() {
|
|
500
|
-
const yaml = readFileSync(
|
|
501
|
-
join(__dirname, "..", ".github", "workflows", "pr-quality.yml"),
|
|
502
|
-
"utf8",
|
|
503
|
-
);
|
|
504
|
-
const lines = yaml.split(/\r?\n/);
|
|
505
|
-
const startIdx = lines.findIndex((l) =>
|
|
506
|
-
/node --input-type=module - <<'NODE'\s*$/.test(l),
|
|
507
|
-
);
|
|
508
|
-
assert.ok(
|
|
509
|
-
startIdx !== -1,
|
|
510
|
-
"expected an embedded `<<'NODE'` heredoc in pr-quality.yml",
|
|
511
|
-
);
|
|
512
|
-
// The heredoc opener carries the block-scalar indent; every body line shares
|
|
513
|
-
// at least that indent, and the closing `NODE` sentinel sits at the same
|
|
514
|
-
// indent. Strip it uniformly.
|
|
515
|
-
const indent = lines[startIdx].match(/^(\s*)/)[1];
|
|
516
|
-
const body = [];
|
|
517
|
-
for (let i = startIdx + 1; i < lines.length; i += 1) {
|
|
518
|
-
if (lines[i].trim() === "NODE") return body.join("\n");
|
|
519
|
-
body.push(
|
|
520
|
-
lines[i].startsWith(indent) ? lines[i].slice(indent.length) : lines[i],
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
throw new Error("unterminated `NODE` heredoc in pr-quality.yml gate step");
|
|
524
|
-
}
|
|
499
|
+
const WORKFLOW_FILE = join(__dirname, "..", ".github", "workflows", "pr-quality.yml");
|
|
500
|
+
const GATE_SCRIPT = join(__dirname, "check-coverage-threshold.mjs");
|
|
525
501
|
|
|
526
|
-
//
|
|
527
|
-
//
|
|
502
|
+
// Mirror the workflow step: materialize the side-checkout layout
|
|
503
|
+
// (`_mandrel-platform-scripts/scripts/check-coverage-threshold.mjs`) inside
|
|
504
|
+
// the fixture tree, then run the script exactly as the workflow does.
|
|
528
505
|
function runGateStep(treeRoot, { threshold = "0", metric = "lines" } = {}) {
|
|
529
|
-
const
|
|
530
|
-
|
|
506
|
+
const sideCheckout = join(treeRoot, "_mandrel-platform-scripts", "scripts");
|
|
507
|
+
mkdirSync(sideCheckout, { recursive: true });
|
|
508
|
+
writeFileSync(
|
|
509
|
+
join(sideCheckout, "check-coverage-threshold.mjs"),
|
|
510
|
+
readFileSync(GATE_SCRIPT, "utf8"),
|
|
511
|
+
);
|
|
531
512
|
try {
|
|
532
|
-
const stdout = execFileSync(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
513
|
+
const stdout = execFileSync(
|
|
514
|
+
"node",
|
|
515
|
+
[
|
|
516
|
+
join("_mandrel-platform-scripts", "scripts", "check-coverage-threshold.mjs"),
|
|
517
|
+
"--threshold",
|
|
518
|
+
threshold,
|
|
519
|
+
"--metric",
|
|
520
|
+
metric,
|
|
521
|
+
],
|
|
522
|
+
{ cwd: treeRoot, env: { ...process.env }, encoding: "utf8" },
|
|
523
|
+
);
|
|
541
524
|
return { status: 0, stdout, stderr: "" };
|
|
542
525
|
} catch (err) {
|
|
543
526
|
return {
|
|
@@ -548,6 +531,28 @@ function runGateStep(treeRoot, { threshold = "0", metric = "lines" } = {}) {
|
|
|
548
531
|
}
|
|
549
532
|
}
|
|
550
533
|
|
|
534
|
+
test("pr-quality.yml runs the side-checkout script — no inline coverage heredoc remains (#230)", () => {
|
|
535
|
+
const yaml = readFileSync(WORKFLOW_FILE, "utf8");
|
|
536
|
+
// The gate step invokes the platform script from the side-checkout…
|
|
537
|
+
assert.match(
|
|
538
|
+
yaml,
|
|
539
|
+
/node _mandrel-platform-scripts\/scripts\/check-coverage-threshold\.mjs/,
|
|
540
|
+
"the Coverage threshold gate must run scripts/check-coverage-threshold.mjs from the side-checkout",
|
|
541
|
+
);
|
|
542
|
+
// …and the migration guard does the same.
|
|
543
|
+
assert.match(
|
|
544
|
+
yaml,
|
|
545
|
+
/node _mandrel-platform-scripts\/scripts\/check-destructive-migration\.mjs/,
|
|
546
|
+
"the migration guard must run scripts/check-destructive-migration.mjs from the side-checkout",
|
|
547
|
+
);
|
|
548
|
+
// The old inlined coverage-discovery copy (the #163 drift class) is gone.
|
|
549
|
+
assert.doesNotMatch(
|
|
550
|
+
yaml,
|
|
551
|
+
/findCoverageSummaries|intentionally duplicated|keep the two in sync/i,
|
|
552
|
+
"no inlined copy of the coverage gate may remain in pr-quality.yml",
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
551
556
|
test("pr-quality gate step discovers a per-workspace coverage/<ws>/ layout (#163 regression)", () => {
|
|
552
557
|
const dir = mkdtempSync(join(tmpdir(), "gate-step-fanout-"));
|
|
553
558
|
try {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* node scripts/check-destructive-migration.mjs \
|
|
37
37
|
* --changed-files <file-with-one-path-per-line> \
|
|
38
38
|
* [--label-present] \
|
|
39
|
+
* [--override-label <name>] \
|
|
39
40
|
* [--migration-glob '**/migrations/**,**/drizzle/**'] \
|
|
40
41
|
* [--repo-root <dir>]
|
|
41
42
|
*
|
|
@@ -45,10 +46,18 @@
|
|
|
45
46
|
* • --label-present Pass when the override acknowledgement label is on the
|
|
46
47
|
* PR. Overrides a destructive finding (exit 0 with a
|
|
47
48
|
* warning) instead of blocking.
|
|
49
|
+
* • --override-label The acknowledgement label NAME to cite in messages and
|
|
50
|
+
* the step summary (behaviour is still driven solely by
|
|
51
|
+
* --label-present). Default `migration:destructive-ok`.
|
|
48
52
|
* • --migration-glob Comma-separated migration path globs. Default
|
|
49
53
|
* `**/migrations/**,**/drizzle/**`.
|
|
50
54
|
* • --repo-root Root to resolve changed-file paths against. Default cwd.
|
|
51
55
|
*
|
|
56
|
+
* When `GITHUB_STEP_SUMMARY` is set (i.e. running inside a GitHub Actions
|
|
57
|
+
* step) and a destructive finding exists, a markdown summary block (ALLOWED
|
|
58
|
+
* via override / BLOCKED) is appended to that file — the same job-summary
|
|
59
|
+
* surface the previous in-workflow bash implementation wrote.
|
|
60
|
+
*
|
|
52
61
|
* Exit codes:
|
|
53
62
|
* 0 — no destructive migration in the changed set, OR a destructive
|
|
54
63
|
* migration is present AND the override label is applied.
|
|
@@ -60,7 +69,7 @@
|
|
|
60
69
|
* docs/reusable-workflows.md (`pr-quality.yml` → migration guard).
|
|
61
70
|
*/
|
|
62
71
|
|
|
63
|
-
import { readFileSync } from "node:fs";
|
|
72
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
64
73
|
import { resolve } from "node:path";
|
|
65
74
|
|
|
66
75
|
// The override acknowledgement label. Documented in docs/reusable-workflows.md.
|
|
@@ -205,14 +214,47 @@ export function detectDestructiveMigrations({ changedFiles, readFile, globs = DE
|
|
|
205
214
|
return { destructive: findings.length > 0, findings };
|
|
206
215
|
}
|
|
207
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Render the GitHub job-summary markdown block for a destructive finding —
|
|
219
|
+
* the same summary surface the previous in-workflow bash implementation
|
|
220
|
+
* appended to `GITHUB_STEP_SUMMARY`. Only called when findings exist.
|
|
221
|
+
*
|
|
222
|
+
* @param {object} opts
|
|
223
|
+
* @param {Array<{file: string, signals: string[]}>} opts.findings
|
|
224
|
+
* @param {boolean} opts.labelPresent
|
|
225
|
+
* @param {string} opts.overrideLabel
|
|
226
|
+
* @returns {string} Markdown, trailing-newline-terminated.
|
|
227
|
+
*/
|
|
228
|
+
export function formatStepSummary({ findings, labelPresent, overrideLabel }) {
|
|
229
|
+
const list = findings
|
|
230
|
+
.map((f) => ` • ${f.file} → ${f.signals.join(", ")}`)
|
|
231
|
+
.join("\n");
|
|
232
|
+
if (labelPresent) {
|
|
233
|
+
return (
|
|
234
|
+
"### Destructive-migration guard — ALLOWED via override\n\n" +
|
|
235
|
+
`Override label \`${overrideLabel}\` is present. Findings:\n\n` +
|
|
236
|
+
`${list}\n`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return (
|
|
240
|
+
"### ❌ Destructive-migration guard — BLOCKED\n\n" +
|
|
241
|
+
"A destructive migration was detected and the override label\n" +
|
|
242
|
+
`\`${overrideLabel}\` is NOT applied. Findings:\n\n` +
|
|
243
|
+
`${list}\n\n` +
|
|
244
|
+
`A reviewer must apply the \`${overrideLabel}\` label to\n` +
|
|
245
|
+
"acknowledge the destructive change, then re-run this check.\n"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
208
249
|
// ---------------------------------------------------------------------------
|
|
209
250
|
// CLI
|
|
210
251
|
// ---------------------------------------------------------------------------
|
|
211
252
|
|
|
212
|
-
function parseArgs(argv) {
|
|
253
|
+
export function parseArgs(argv) {
|
|
213
254
|
const opts = {
|
|
214
255
|
changedFiles: null,
|
|
215
256
|
labelPresent: false,
|
|
257
|
+
overrideLabel: DEFAULT_OVERRIDE_LABEL,
|
|
216
258
|
globs: DEFAULT_MIGRATION_GLOBS,
|
|
217
259
|
repoRoot: process.cwd(),
|
|
218
260
|
};
|
|
@@ -222,6 +264,8 @@ function parseArgs(argv) {
|
|
|
222
264
|
opts.changedFiles = argv[++i];
|
|
223
265
|
} else if (a === "--label-present") {
|
|
224
266
|
opts.labelPresent = true;
|
|
267
|
+
} else if (a === "--override-label" && argv[i + 1]) {
|
|
268
|
+
opts.overrideLabel = argv[++i];
|
|
225
269
|
} else if (a === "--migration-glob" && argv[i + 1]) {
|
|
226
270
|
opts.globs = argv[++i]
|
|
227
271
|
.split(",")
|
|
@@ -252,7 +296,8 @@ function main() {
|
|
|
252
296
|
if (opts.help) {
|
|
253
297
|
process.stdout.write(
|
|
254
298
|
"Usage: node scripts/check-destructive-migration.mjs --changed-files <path|-> " +
|
|
255
|
-
"[--label-present] [--
|
|
299
|
+
"[--label-present] [--override-label <name>] [--migration-glob <csv>] " +
|
|
300
|
+
"[--repo-root <dir>]\n"
|
|
256
301
|
);
|
|
257
302
|
process.exit(0);
|
|
258
303
|
}
|
|
@@ -290,18 +335,35 @@ function main() {
|
|
|
290
335
|
.map((f) => ` • ${f.file} → ${f.signals.join(", ")}`)
|
|
291
336
|
.join("\n");
|
|
292
337
|
|
|
338
|
+
// Inside a GitHub Actions step, mirror the finding onto the job summary —
|
|
339
|
+
// the same surface the previous in-workflow bash implementation wrote.
|
|
340
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
341
|
+
try {
|
|
342
|
+
appendFileSync(
|
|
343
|
+
process.env.GITHUB_STEP_SUMMARY,
|
|
344
|
+
formatStepSummary({
|
|
345
|
+
findings,
|
|
346
|
+
labelPresent: opts.labelPresent,
|
|
347
|
+
overrideLabel: opts.overrideLabel,
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
} catch {
|
|
351
|
+
// Best-effort: the exit code below is the gate, the summary is cosmetic.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
293
355
|
if (opts.labelPresent) {
|
|
294
356
|
process.stdout.write(
|
|
295
357
|
`⚠️ Destructive migration detected, but the override label ` +
|
|
296
|
-
`'${
|
|
358
|
+
`'${opts.overrideLabel}' is applied — allowing.\n${summary}\n`
|
|
297
359
|
);
|
|
298
360
|
process.exit(0);
|
|
299
361
|
}
|
|
300
362
|
|
|
301
363
|
process.stderr.write(
|
|
302
364
|
`❌ Destructive migration detected and the override label ` +
|
|
303
|
-
`'${
|
|
304
|
-
`To proceed, a reviewer must apply the '${
|
|
365
|
+
`'${opts.overrideLabel}' is NOT applied — blocking.\n${summary}\n\n` +
|
|
366
|
+
`To proceed, a reviewer must apply the '${opts.overrideLabel}' label ` +
|
|
305
367
|
`to acknowledge the destructive change, then re-run this check.\n`
|
|
306
368
|
);
|
|
307
369
|
process.exit(1);
|
|
@@ -14,18 +14,27 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import assert from "node:assert/strict";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
17
22
|
import { test } from "node:test";
|
|
18
23
|
|
|
19
24
|
import {
|
|
20
25
|
DEFAULT_MIGRATION_GLOBS,
|
|
21
26
|
DEFAULT_OVERRIDE_LABEL,
|
|
22
27
|
detectDestructiveMigrations,
|
|
28
|
+
formatStepSummary,
|
|
23
29
|
globToRegExp,
|
|
24
30
|
isMigrationFile,
|
|
31
|
+
parseArgs,
|
|
25
32
|
scanMigrationText,
|
|
26
33
|
stripComments,
|
|
27
34
|
} from "./check-destructive-migration.mjs";
|
|
28
35
|
|
|
36
|
+
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "check-destructive-migration.mjs");
|
|
37
|
+
|
|
29
38
|
// Build a readFile seam from an in-memory { path: text } map.
|
|
30
39
|
function fakeReader(files) {
|
|
31
40
|
return (path) => {
|
|
@@ -181,3 +190,140 @@ test("override label and default globs are the documented contract values", () =
|
|
|
181
190
|
assert.equal(DEFAULT_OVERRIDE_LABEL, "migration:destructive-ok");
|
|
182
191
|
assert.deepEqual(DEFAULT_MIGRATION_GLOBS, ["**/migrations/**", "**/drizzle/**"]);
|
|
183
192
|
});
|
|
193
|
+
|
|
194
|
+
// ── parseArgs: the CLI surface pr-quality.yml drives ───────────────────────
|
|
195
|
+
|
|
196
|
+
test("parseArgs: defaults", () => {
|
|
197
|
+
const opts = parseArgs([]);
|
|
198
|
+
assert.equal(opts.changedFiles, null);
|
|
199
|
+
assert.equal(opts.labelPresent, false);
|
|
200
|
+
assert.equal(opts.overrideLabel, DEFAULT_OVERRIDE_LABEL);
|
|
201
|
+
assert.deepEqual(opts.globs, DEFAULT_MIGRATION_GLOBS);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("parseArgs: --override-label, --label-present, --migration-glob, --changed-files", () => {
|
|
205
|
+
const opts = parseArgs([
|
|
206
|
+
"--changed-files", "-",
|
|
207
|
+
"--label-present",
|
|
208
|
+
"--override-label", "db:drop-ok",
|
|
209
|
+
"--migration-glob", "**/db/changes/**, **/sql/**",
|
|
210
|
+
]);
|
|
211
|
+
assert.equal(opts.changedFiles, "-");
|
|
212
|
+
assert.equal(opts.labelPresent, true);
|
|
213
|
+
assert.equal(opts.overrideLabel, "db:drop-ok");
|
|
214
|
+
assert.deepEqual(opts.globs, ["**/db/changes/**", "**/sql/**"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── formatStepSummary: the job-summary block parity contract ────────────────
|
|
218
|
+
|
|
219
|
+
test("formatStepSummary: blocked shape names the override label and findings", () => {
|
|
220
|
+
const md = formatStepSummary({
|
|
221
|
+
findings: [{ file: "db/migrations/0010_drop.sql", signals: ["DROP statement"] }],
|
|
222
|
+
labelPresent: false,
|
|
223
|
+
overrideLabel: "migration:destructive-ok",
|
|
224
|
+
});
|
|
225
|
+
assert.ok(md.includes("### ❌ Destructive-migration guard — BLOCKED"));
|
|
226
|
+
assert.ok(md.includes("`migration:destructive-ok`"));
|
|
227
|
+
assert.ok(md.includes("db/migrations/0010_drop.sql → DROP statement"));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("formatStepSummary: allowed shape reports the finding without blocking language", () => {
|
|
231
|
+
const md = formatStepSummary({
|
|
232
|
+
findings: [{ file: "db/migrations/0010_drop.sql", signals: ["TRUNCATE"] }],
|
|
233
|
+
labelPresent: true,
|
|
234
|
+
overrideLabel: "db:drop-ok",
|
|
235
|
+
});
|
|
236
|
+
assert.ok(md.includes("### Destructive-migration guard — ALLOWED via override"));
|
|
237
|
+
assert.ok(md.includes("`db:drop-ok`"));
|
|
238
|
+
assert.ok(!md.includes("BLOCKED"));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── CLI end-to-end: exit codes are the gate (behavioural-parity proof) ──────
|
|
242
|
+
|
|
243
|
+
function runCli(args, { input = "", env = {} } = {}) {
|
|
244
|
+
try {
|
|
245
|
+
const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
|
|
246
|
+
input,
|
|
247
|
+
encoding: "utf8",
|
|
248
|
+
env: { ...process.env, GITHUB_STEP_SUMMARY: "", ...env },
|
|
249
|
+
});
|
|
250
|
+
return { code: 0, stdout, stderr: "" };
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { code: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
test("CLI: destructive migration without the label blocks (exit 1)", () => {
|
|
257
|
+
const root = mkdtempSync(join(tmpdir(), "destmig-"));
|
|
258
|
+
try {
|
|
259
|
+
mkdirSync(join(root, "db", "migrations"), { recursive: true });
|
|
260
|
+
writeFileSync(join(root, "db", "migrations", "0010_drop.sql"), "DROP TABLE users;\n");
|
|
261
|
+
const res = runCli(
|
|
262
|
+
["--changed-files", "-", "--repo-root", root, "--override-label", "db:drop-ok"],
|
|
263
|
+
{ input: "db/migrations/0010_drop.sql\n" }
|
|
264
|
+
);
|
|
265
|
+
assert.equal(res.code, 1);
|
|
266
|
+
assert.ok(res.stderr.includes("'db:drop-ok' is NOT applied — blocking"));
|
|
267
|
+
} finally {
|
|
268
|
+
rmSync(root, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("CLI: the override label downgrades the block to a warning (exit 0)", () => {
|
|
273
|
+
const root = mkdtempSync(join(tmpdir(), "destmig-"));
|
|
274
|
+
try {
|
|
275
|
+
mkdirSync(join(root, "db", "migrations"), { recursive: true });
|
|
276
|
+
writeFileSync(join(root, "db", "migrations", "0010_drop.sql"), "DROP TABLE users;\n");
|
|
277
|
+
const res = runCli(
|
|
278
|
+
["--changed-files", "-", "--repo-root", root, "--label-present"],
|
|
279
|
+
{ input: "db/migrations/0010_drop.sql\n" }
|
|
280
|
+
);
|
|
281
|
+
assert.equal(res.code, 0);
|
|
282
|
+
assert.ok(res.stdout.includes(`'${DEFAULT_OVERRIDE_LABEL}' is applied — allowing`));
|
|
283
|
+
} finally {
|
|
284
|
+
rmSync(root, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("CLI: comment-only DROP does not trip the guard (exit 0)", () => {
|
|
289
|
+
const root = mkdtempSync(join(tmpdir(), "destmig-"));
|
|
290
|
+
try {
|
|
291
|
+
mkdirSync(join(root, "db", "migrations"), { recursive: true });
|
|
292
|
+
writeFileSync(
|
|
293
|
+
join(root, "db", "migrations", "0011_note.sql"),
|
|
294
|
+
"-- DROP TABLE users; (rolled back)\nALTER TABLE users ADD COLUMN nickname TEXT;\n"
|
|
295
|
+
);
|
|
296
|
+
const res = runCli(["--changed-files", "-", "--repo-root", root], {
|
|
297
|
+
input: "db/migrations/0011_note.sql\n",
|
|
298
|
+
});
|
|
299
|
+
assert.equal(res.code, 0);
|
|
300
|
+
assert.ok(res.stdout.includes("No destructive migration detected"));
|
|
301
|
+
} finally {
|
|
302
|
+
rmSync(root, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("CLI: missing --changed-files is a usage error (exit 2)", () => {
|
|
307
|
+
const res = runCli([]);
|
|
308
|
+
assert.equal(res.code, 2);
|
|
309
|
+
assert.ok(res.stderr.includes("--changed-files"));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("CLI: a finding writes the GITHUB_STEP_SUMMARY block when the env var is set", () => {
|
|
313
|
+
const root = mkdtempSync(join(tmpdir(), "destmig-"));
|
|
314
|
+
try {
|
|
315
|
+
mkdirSync(join(root, "db", "migrations"), { recursive: true });
|
|
316
|
+
writeFileSync(join(root, "db", "migrations", "0010_drop.sql"), "TRUNCATE audit_log;\n");
|
|
317
|
+
const summaryFile = join(root, "step-summary.md");
|
|
318
|
+
const res = runCli(
|
|
319
|
+
["--changed-files", "-", "--repo-root", root],
|
|
320
|
+
{ input: "db/migrations/0010_drop.sql\n", env: { GITHUB_STEP_SUMMARY: summaryFile } }
|
|
321
|
+
);
|
|
322
|
+
assert.equal(res.code, 1);
|
|
323
|
+
const summary = readFileSync(summaryFile, "utf8");
|
|
324
|
+
assert.ok(summary.includes("### ❌ Destructive-migration guard — BLOCKED"));
|
|
325
|
+
assert.ok(summary.includes("TRUNCATE"));
|
|
326
|
+
} finally {
|
|
327
|
+
rmSync(root, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|