mandrel-platform 0.17.2 → 0.18.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/README.md +220 -14
- package/config/commitlint.base.mjs +36 -0
- package/config/repo-settings.schema.json +78 -0
- package/package.json +2 -1
- package/scripts/apply-uptime-monitors.mjs +378 -0
- package/scripts/apply-uptime-monitors.test.mjs +372 -0
- package/scripts/check-repo-settings.mjs +363 -0
- package/scripts/check-repo-settings.test.mjs +320 -0
- package/scripts/check-required-contexts.mjs +247 -129
- package/scripts/check-required-contexts.test.mjs +137 -0
- package/scripts/check-ruleset.mjs +435 -0
- package/scripts/check-ruleset.test.mjs +439 -0
- package/scripts/check-wrangler-baseline.mjs +514 -0
- package/scripts/check-wrangler-baseline.test.mjs +454 -0
- package/scripts/platform-sync.mjs +533 -5
- package/scripts/platform-sync.test.mjs +477 -0
- package/templates/workflows/deploy-staging.yml +86 -0
- package/templates/workflows/uptime-apply.yml +54 -0
|
@@ -17,8 +17,20 @@
|
|
|
17
17
|
* node scripts/check-required-contexts.mjs --contract path/to/main-protection.json
|
|
18
18
|
* node scripts/check-required-contexts.mjs --workflows-dir .github/workflows
|
|
19
19
|
*
|
|
20
|
+
* Also warns (never blocks) when the caller-naming triplet — the CI workflow
|
|
21
|
+
* file name, its display `name:`, and the caller job id that wraps the
|
|
22
|
+
* `pr-quality.yml` call — diverges from the canonical shape documented in
|
|
23
|
+
* `docs/reusable-workflows.md` (`ci.yml` / `CI` / caller job id `ci`, giving
|
|
24
|
+
* the required context `ci / ci-required`). Three fleet consumers converged
|
|
25
|
+
* on three different spellings for the same shared workflow (validated
|
|
26
|
+
* 2026-07-01: domio `ci-pr.yml` / `CI (PR)`; athportal `quality.yml` /
|
|
27
|
+
* `quality`; swarm-os `ci.yml` / `CI` with job id `quality`) — this is a
|
|
28
|
+
* lint nudge toward convergence, not a gate, so existing non-canonical
|
|
29
|
+
* consumers are never blocked by adopting this script.
|
|
30
|
+
*
|
|
20
31
|
* Exit codes:
|
|
21
32
|
* 0 — all required contexts are emitted by at least one workflow job
|
|
33
|
+
* (regardless of caller-naming warnings — those never affect the exit code)
|
|
22
34
|
* 1 — one or more phantom contexts detected (named in stderr)
|
|
23
35
|
*
|
|
24
36
|
* Consumer adoption:
|
|
@@ -36,63 +48,11 @@ import { readFileSync, readdirSync } from "node:fs";
|
|
|
36
48
|
import { resolve, join, relative } from "node:path";
|
|
37
49
|
|
|
38
50
|
// ---------------------------------------------------------------------------
|
|
39
|
-
//
|
|
51
|
+
// Repo-root resolution (used by both the CLI entrypoint below and the
|
|
52
|
+
// exported naming-lint helper, which reports paths relative to it)
|
|
40
53
|
// ---------------------------------------------------------------------------
|
|
41
54
|
|
|
42
|
-
const args = process.argv.slice(2);
|
|
43
|
-
let contractPath = null;
|
|
44
|
-
let workflowsDir = null;
|
|
45
|
-
|
|
46
|
-
for (let i = 0; i < args.length; i++) {
|
|
47
|
-
if ((args[i] === "--contract" || args[i] === "-c") && args[i + 1]) {
|
|
48
|
-
contractPath = args[++i];
|
|
49
|
-
} else if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
|
|
50
|
-
workflowsDir = args[++i];
|
|
51
|
-
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
52
|
-
process.stdout.write(
|
|
53
|
-
"Usage: node scripts/check-required-contexts.mjs [--contract <path>] [--workflows-dir <dir>]\n"
|
|
54
|
-
);
|
|
55
|
-
process.exit(0);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Resolve paths relative to the repo root (cwd when invoked from CI or locally).
|
|
60
55
|
const repoRoot = process.cwd();
|
|
61
|
-
const resolvedContract = contractPath
|
|
62
|
-
? resolve(contractPath)
|
|
63
|
-
: resolve(repoRoot, "docs/runbooks/main-protection.json");
|
|
64
|
-
const resolvedWorkflowsDir = workflowsDir
|
|
65
|
-
? resolve(workflowsDir)
|
|
66
|
-
: resolve(repoRoot, ".github/workflows");
|
|
67
|
-
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// Load contract
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
let contract;
|
|
73
|
-
try {
|
|
74
|
-
const raw = readFileSync(resolvedContract, "utf8");
|
|
75
|
-
contract = JSON.parse(raw);
|
|
76
|
-
} catch (err) {
|
|
77
|
-
process.stderr.write(
|
|
78
|
-
`[check-required-contexts] ERROR: Cannot read contract at ${relative(repoRoot, resolvedContract)}: ${err.message}\n`
|
|
79
|
-
);
|
|
80
|
-
process.exit(1);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const requiredContexts = Array.isArray(contract.requiredStatusChecks)
|
|
84
|
-
? contract.requiredStatusChecks
|
|
85
|
-
: [];
|
|
86
|
-
const upstreamJobs = Array.isArray(contract.upstreamJobs)
|
|
87
|
-
? contract.upstreamJobs
|
|
88
|
-
: [];
|
|
89
|
-
|
|
90
|
-
if (requiredContexts.length === 0) {
|
|
91
|
-
process.stderr.write(
|
|
92
|
-
"[check-required-contexts] ERROR: contract.requiredStatusChecks is empty — at least one context is required.\n"
|
|
93
|
-
);
|
|
94
|
-
process.exit(1);
|
|
95
|
-
}
|
|
96
56
|
|
|
97
57
|
// ---------------------------------------------------------------------------
|
|
98
58
|
// Collect job names from all workflow files
|
|
@@ -114,7 +74,7 @@ if (requiredContexts.length === 0) {
|
|
|
114
74
|
*
|
|
115
75
|
* The job ID line is indented by exactly 2 spaces and ends with a colon.
|
|
116
76
|
*/
|
|
117
|
-
function extractJobIds(yamlContent) {
|
|
77
|
+
export function extractJobIds(yamlContent) {
|
|
118
78
|
const ids = new Set();
|
|
119
79
|
let inJobsBlock = false;
|
|
120
80
|
|
|
@@ -143,105 +103,263 @@ function extractJobIds(yamlContent) {
|
|
|
143
103
|
return ids;
|
|
144
104
|
}
|
|
145
105
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Extract the workflow's top-level display `name:` (the value GitHub Actions
|
|
108
|
+
* shows in the UI and uses as the first segment of a workflow_call caller's
|
|
109
|
+
* status context, e.g. "CI / ci-required"). Returns null when no top-level
|
|
110
|
+
* `name:` key is present (GitHub then falls back to the file path).
|
|
111
|
+
*
|
|
112
|
+
* Only the zero-indentation `name:` key qualifies — job-level `name:` keys
|
|
113
|
+
* are indented and must not be mistaken for the workflow's own display name.
|
|
114
|
+
*/
|
|
115
|
+
export function extractWorkflowName(yamlContent) {
|
|
116
|
+
for (const line of yamlContent.split("\n")) {
|
|
117
|
+
const match = line.match(/^name:\s*(.+?)\s*$/);
|
|
118
|
+
if (match) {
|
|
119
|
+
return match[1].replace(/^["']|["']$/g, "");
|
|
120
|
+
}
|
|
121
|
+
// Stop scanning once we reach `on:` or `jobs:` — `name:` is always a
|
|
122
|
+
// top-of-file key in a well-formed workflow, so nothing past those
|
|
123
|
+
// markers should be mistaken for it.
|
|
124
|
+
if (/^(on|jobs):\s*$/.test(line)) break;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
156
127
|
}
|
|
157
128
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
process.exit(1);
|
|
163
|
-
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Canonical caller-naming lint (warn-only — see docs/reusable-workflows.md
|
|
131
|
+
// "Canonical caller naming" for the full rationale and target shape).
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
164
133
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
134
|
+
export const CANONICAL_CALLER = {
|
|
135
|
+
file: "ci.yml",
|
|
136
|
+
displayName: "CI",
|
|
137
|
+
jobId: "ci",
|
|
138
|
+
};
|
|
169
139
|
|
|
170
|
-
|
|
171
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Non-blocking nudge toward the canonical `ci.yml` / `CI` / `ci` caller
|
|
142
|
+
* naming triplet (operator decision 2026-07-01, D2). Never affects the exit
|
|
143
|
+
* code — this is drift *signal*, not a gate, so existing consumers already on
|
|
144
|
+
* a non-canonical spelling (domio's `ci-pr.yml`, athportal's `quality.yml`,
|
|
145
|
+
* swarm-os's `quality` job id) are never blocked by adopting this script.
|
|
146
|
+
*/
|
|
147
|
+
export function warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, resolvedWorkflowsDir) {
|
|
148
|
+
const warnings = [];
|
|
149
|
+
|
|
150
|
+
if (!workflowFiles.includes(CANONICAL_CALLER.file)) {
|
|
151
|
+
warnings.push(
|
|
152
|
+
`no "${CANONICAL_CALLER.file}" file found in ${relative(repoRoot, resolvedWorkflowsDir)}/ — ` +
|
|
153
|
+
`the canonical caller file name is "${CANONICAL_CALLER.file}" (found: ${workflowFiles.join(", ")})`
|
|
154
|
+
);
|
|
155
|
+
return warnings;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const filePath = join(resolvedWorkflowsDir, CANONICAL_CALLER.file);
|
|
172
159
|
let content;
|
|
173
160
|
try {
|
|
174
161
|
content = readFileSync(filePath, "utf8");
|
|
175
|
-
} catch
|
|
176
|
-
|
|
177
|
-
|
|
162
|
+
} catch {
|
|
163
|
+
return warnings;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const displayName = extractWorkflowName(content);
|
|
167
|
+
if (displayName !== CANONICAL_CALLER.displayName) {
|
|
168
|
+
warnings.push(
|
|
169
|
+
`${CANONICAL_CALLER.file} display name is "${displayName ?? "(none)"}" — ` +
|
|
170
|
+
`canonical is "${CANONICAL_CALLER.displayName}"`
|
|
178
171
|
);
|
|
179
|
-
continue;
|
|
180
172
|
}
|
|
181
|
-
|
|
182
|
-
workflowJobMap.
|
|
183
|
-
|
|
184
|
-
|
|
173
|
+
|
|
174
|
+
const jobIds = workflowJobMap.get(CANONICAL_CALLER.file) ?? new Set();
|
|
175
|
+
if (!jobIds.has(CANONICAL_CALLER.jobId)) {
|
|
176
|
+
warnings.push(
|
|
177
|
+
`${CANONICAL_CALLER.file} has no "${CANONICAL_CALLER.jobId}" job id — ` +
|
|
178
|
+
`canonical required context is "${CANONICAL_CALLER.jobId} / ci-required" ` +
|
|
179
|
+
`(found job ids: ${[...jobIds].sort().join(", ") || "(none)"})`
|
|
180
|
+
);
|
|
185
181
|
}
|
|
182
|
+
|
|
183
|
+
return warnings;
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
// ---------------------------------------------------------------------------
|
|
189
|
-
//
|
|
187
|
+
// CLI entrypoint (skipped under `node --test` import — see the guard below)
|
|
190
188
|
// ---------------------------------------------------------------------------
|
|
191
189
|
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
export function runCli(argv) {
|
|
191
|
+
// ── Arg parsing ────────────────────────────────────────────────────────
|
|
192
|
+
let contractPath = null;
|
|
193
|
+
let workflowsDir = null;
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
for (let i = 0; i < argv.length; i++) {
|
|
196
|
+
if ((argv[i] === "--contract" || argv[i] === "-c") && argv[i + 1]) {
|
|
197
|
+
contractPath = argv[++i];
|
|
198
|
+
} else if ((argv[i] === "--workflows-dir" || argv[i] === "-w") && argv[i + 1]) {
|
|
199
|
+
workflowsDir = argv[++i];
|
|
200
|
+
} else if (argv[i] === "--help" || argv[i] === "-h") {
|
|
201
|
+
process.stdout.write(
|
|
202
|
+
"Usage: node scripts/check-required-contexts.mjs [--contract <path>] [--workflows-dir <dir>]\n"
|
|
203
|
+
);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
198
207
|
|
|
199
|
-
|
|
200
|
-
const
|
|
208
|
+
// Resolve paths relative to the repo root (cwd when invoked from CI or locally).
|
|
209
|
+
const resolvedContract = contractPath
|
|
210
|
+
? resolve(contractPath)
|
|
211
|
+
: resolve(repoRoot, "docs/runbooks/main-protection.json");
|
|
212
|
+
const resolvedWorkflowsDir = workflowsDir
|
|
213
|
+
? resolve(workflowsDir)
|
|
214
|
+
: resolve(repoRoot, ".github/workflows");
|
|
201
215
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
216
|
+
// ── Load contract ──────────────────────────────────────────────────────
|
|
217
|
+
let contract;
|
|
218
|
+
try {
|
|
219
|
+
const raw = readFileSync(resolvedContract, "utf8");
|
|
220
|
+
contract = JSON.parse(raw);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`[check-required-contexts] ERROR: Cannot read contract at ${relative(repoRoot, resolvedContract)}: ${err.message}\n`
|
|
224
|
+
);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
207
227
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
process.stderr.write(
|
|
228
|
+
const requiredContexts = Array.isArray(contract.requiredStatusChecks)
|
|
229
|
+
? contract.requiredStatusChecks
|
|
230
|
+
: [];
|
|
231
|
+
const upstreamJobs = Array.isArray(contract.upstreamJobs) ? contract.upstreamJobs : [];
|
|
232
|
+
|
|
233
|
+
if (requiredContexts.length === 0) {
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
"[check-required-contexts] ERROR: contract.requiredStatusChecks is empty — at least one context is required.\n"
|
|
236
|
+
);
|
|
237
|
+
return 1;
|
|
215
238
|
}
|
|
216
|
-
process.stderr.write(
|
|
217
|
-
`\n A phantom context will block every PR indefinitely on a "pending" status\n` +
|
|
218
|
-
` that never resolves. Fix: either add a workflow job with this exact ID,\n` +
|
|
219
|
-
` or remove the context from requiredStatusChecks in ${contractRel}.\n\n`
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
239
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
// ── Collect job names from all workflow files ─────────────────────────
|
|
241
|
+
let workflowFiles;
|
|
242
|
+
try {
|
|
243
|
+
workflowFiles = readdirSync(resolvedWorkflowsDir).filter(
|
|
244
|
+
(f) => f.endsWith(".yml") || f.endsWith(".yaml")
|
|
245
|
+
);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
process.stderr.write(
|
|
248
|
+
`[check-required-contexts] ERROR: Cannot read workflows directory at ${relative(repoRoot, resolvedWorkflowsDir)}: ${err.message}\n`
|
|
249
|
+
);
|
|
250
|
+
return 1;
|
|
230
251
|
}
|
|
231
|
-
process.stderr.write(
|
|
232
|
-
`\n Fix: either add the missing job to a workflow, or remove it from\n` +
|
|
233
|
-
` upstreamJobs in ${contractRel}.\n\n`
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
252
|
|
|
237
|
-
|
|
253
|
+
if (workflowFiles.length === 0) {
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
`[check-required-contexts] ERROR: No workflow files found in ${relative(repoRoot, resolvedWorkflowsDir)}\n`
|
|
256
|
+
);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Map from workflow filename → Set<jobId> */
|
|
261
|
+
const workflowJobMap = new Map();
|
|
262
|
+
/** Flat set of all job IDs across all workflows */
|
|
263
|
+
const allJobIds = new Set();
|
|
264
|
+
|
|
265
|
+
for (const file of workflowFiles) {
|
|
266
|
+
const filePath = join(resolvedWorkflowsDir, file);
|
|
267
|
+
let content;
|
|
268
|
+
try {
|
|
269
|
+
content = readFileSync(filePath, "utf8");
|
|
270
|
+
} catch (err) {
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
`[check-required-contexts] WARN: Cannot read ${file}: ${err.message} — skipping.\n`
|
|
273
|
+
);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const ids = extractJobIds(content);
|
|
277
|
+
workflowJobMap.set(file, ids);
|
|
278
|
+
for (const id of ids) {
|
|
279
|
+
allJobIds.add(id);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Validate required contexts ─────────────────────────────────────────
|
|
284
|
+
const phantomContexts = requiredContexts.filter((ctx) => !allJobIds.has(ctx));
|
|
285
|
+
const phantomUpstream = upstreamJobs.filter((job) => !allJobIds.has(job));
|
|
286
|
+
|
|
287
|
+
// ── Report ──────────────────────────────────────────────────────────────
|
|
288
|
+
const contractRel = relative(repoRoot, resolvedContract);
|
|
289
|
+
const workflowsRel = relative(repoRoot, resolvedWorkflowsDir);
|
|
238
290
|
|
|
239
|
-
if (!hasError) {
|
|
240
291
|
process.stdout.write(
|
|
241
|
-
`[check-required-contexts]
|
|
242
|
-
`
|
|
243
|
-
`
|
|
292
|
+
`[check-required-contexts] Contract : ${contractRel}\n` +
|
|
293
|
+
`[check-required-contexts] Workflows: ${workflowsRel}/ (${workflowFiles.length} file${workflowFiles.length === 1 ? "" : "s"})\n` +
|
|
294
|
+
`[check-required-contexts] Emitted job IDs: ${[...allJobIds].sort().join(", ")}\n`
|
|
244
295
|
);
|
|
296
|
+
|
|
297
|
+
if (phantomContexts.length > 0) {
|
|
298
|
+
process.stderr.write(
|
|
299
|
+
`\n[check-required-contexts] ❌ PHANTOM required contexts detected!\n` +
|
|
300
|
+
` These contexts are listed in requiredStatusChecks but no workflow job emits them:\n`
|
|
301
|
+
);
|
|
302
|
+
for (const ctx of phantomContexts) {
|
|
303
|
+
process.stderr.write(` • "${ctx}"\n`);
|
|
304
|
+
}
|
|
305
|
+
process.stderr.write(
|
|
306
|
+
`\n A phantom context will block every PR indefinitely on a "pending" status\n` +
|
|
307
|
+
` that never resolves. Fix: either add a workflow job with this exact ID,\n` +
|
|
308
|
+
` or remove the context from requiredStatusChecks in ${contractRel}.\n\n`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (phantomUpstream.length > 0) {
|
|
313
|
+
process.stderr.write(
|
|
314
|
+
`\n[check-required-contexts] ❌ PHANTOM upstream jobs detected!\n` +
|
|
315
|
+
` These jobs are listed in upstreamJobs but no workflow defines them:\n`
|
|
316
|
+
);
|
|
317
|
+
for (const job of phantomUpstream) {
|
|
318
|
+
process.stderr.write(` • "${job}"\n`);
|
|
319
|
+
}
|
|
320
|
+
process.stderr.write(
|
|
321
|
+
`\n Fix: either add the missing job to a workflow, or remove it from\n` +
|
|
322
|
+
` upstreamJobs in ${contractRel}.\n\n`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const namingWarnings = warnOnNonCanonicalCallerNaming(
|
|
327
|
+
workflowFiles,
|
|
328
|
+
workflowJobMap,
|
|
329
|
+
resolvedWorkflowsDir
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
if (namingWarnings.length > 0) {
|
|
333
|
+
process.stdout.write(
|
|
334
|
+
`\n[check-required-contexts] ⚠️ Non-canonical CI caller naming (warn-only, does not fail this check):\n`
|
|
335
|
+
);
|
|
336
|
+
for (const warning of namingWarnings) {
|
|
337
|
+
process.stdout.write(` • ${warning}\n`);
|
|
338
|
+
}
|
|
339
|
+
process.stdout.write(
|
|
340
|
+
`\n See docs/reusable-workflows.md § "Canonical caller naming" for the\n` +
|
|
341
|
+
` target shape (file "${CANONICAL_CALLER.file}", display name "${CANONICAL_CALLER.displayName}",\n` +
|
|
342
|
+
` caller job id "${CANONICAL_CALLER.jobId}" → required context "${CANONICAL_CALLER.jobId} / ci-required").\n` +
|
|
343
|
+
` Renaming an existing caller is a per-consumer migration, not required by this lint.\n\n`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const hasError = phantomContexts.length > 0 || phantomUpstream.length > 0;
|
|
348
|
+
|
|
349
|
+
if (!hasError) {
|
|
350
|
+
process.stdout.write(
|
|
351
|
+
`[check-required-contexts] ✅ All required contexts and upstream jobs are emitted by CI.\n` +
|
|
352
|
+
` requiredStatusChecks : ${requiredContexts.join(", ")}\n` +
|
|
353
|
+
` upstreamJobs : ${upstreamJobs.length > 0 ? upstreamJobs.join(", ") : "(none listed)"}\n`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return hasError ? 1 : 0;
|
|
245
358
|
}
|
|
246
359
|
|
|
247
|
-
|
|
360
|
+
// Only run when executed directly, not when imported by the test suite.
|
|
361
|
+
const invokedDirectly =
|
|
362
|
+
process.argv[1] && resolve(process.argv[1]).endsWith("check-required-contexts.mjs");
|
|
363
|
+
if (invokedDirectly) {
|
|
364
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
365
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-required-contexts.test.mjs — node:test suite for the canonical
|
|
4
|
+
* CI-caller-naming lint added to check-required-contexts.mjs (Story #173).
|
|
5
|
+
*
|
|
6
|
+
* The pre-existing phantom-context checker is exercised end-to-end via the
|
|
7
|
+
* CLI (see docs/reusable-workflows.md § "Canonical caller naming" for the
|
|
8
|
+
* target shape this lint nudges consumers toward). This suite covers the
|
|
9
|
+
* three pure functions the naming lint adds: `extractWorkflowName`,
|
|
10
|
+
* `extractJobIds` (pre-existing, re-exercised for the new call sites), and
|
|
11
|
+
* `warnOnNonCanonicalCallerNaming` — all offline, no filesystem beyond a
|
|
12
|
+
* scratch temp dir for the workflows-dir fixture.
|
|
13
|
+
*
|
|
14
|
+
* Run: node scripts/check-required-contexts.test.mjs (or `node --test scripts/`)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
CANONICAL_CALLER,
|
|
25
|
+
extractJobIds,
|
|
26
|
+
extractWorkflowName,
|
|
27
|
+
warnOnNonCanonicalCallerNaming,
|
|
28
|
+
} from "./check-required-contexts.mjs";
|
|
29
|
+
|
|
30
|
+
// ── extractWorkflowName ─────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
test("extractWorkflowName: reads the top-level `name:` key", () => {
|
|
33
|
+
const yaml = `name: CI\n\non:\n pull_request:\n branches: [main]\n\njobs:\n ci:\n runs-on: ubuntu-latest\n`;
|
|
34
|
+
assert.equal(extractWorkflowName(yaml), "CI");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("extractWorkflowName: strips surrounding quotes", () => {
|
|
38
|
+
assert.equal(extractWorkflowName('name: "CI (PR)"\non:\n'), "CI (PR)");
|
|
39
|
+
assert.equal(extractWorkflowName("name: 'quality'\non:\n"), "quality");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("extractWorkflowName: returns null when no top-level name: is present", () => {
|
|
43
|
+
const yaml = `on:\n push:\n branches: [main]\njobs:\n build:\n name: Build\n`;
|
|
44
|
+
assert.equal(extractWorkflowName(yaml), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("extractWorkflowName: does not mistake a job-level name: for the workflow name", () => {
|
|
48
|
+
const yaml = `on:\n push:\njobs:\n build:\n name: Build job display name\n`;
|
|
49
|
+
assert.equal(extractWorkflowName(yaml), null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── extractJobIds (pre-existing behaviour, re-exercised for the new caller) ─
|
|
53
|
+
|
|
54
|
+
test("extractJobIds: finds the canonical `ci` job id", () => {
|
|
55
|
+
const yaml = `name: CI\non:\n pull_request:\njobs:\n ci:\n uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@sha\n ci-required:\n needs: [ci]\n`;
|
|
56
|
+
const ids = extractJobIds(yaml);
|
|
57
|
+
assert.ok(ids.has("ci"));
|
|
58
|
+
assert.ok(ids.has("ci-required"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── warnOnNonCanonicalCallerNaming ──────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function withTempWorkflowsDir(files, run) {
|
|
64
|
+
const dir = mkdtempSync(join(tmpdir(), "check-required-contexts-test-"));
|
|
65
|
+
try {
|
|
66
|
+
for (const [name, content] of Object.entries(files)) {
|
|
67
|
+
writeFileSync(join(dir, name), content, "utf8");
|
|
68
|
+
}
|
|
69
|
+
return run(dir);
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("warnOnNonCanonicalCallerNaming: no warnings for the fully canonical shape", () => {
|
|
76
|
+
withTempWorkflowsDir(
|
|
77
|
+
{
|
|
78
|
+
"ci.yml": `name: CI\non:\n pull_request:\njobs:\n ci:\n uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@sha\n ci-required:\n needs: [ci]\n`,
|
|
79
|
+
},
|
|
80
|
+
(dir) => {
|
|
81
|
+
const workflowFiles = ["ci.yml"];
|
|
82
|
+
const workflowJobMap = new Map([["ci.yml", extractJobIds(
|
|
83
|
+
`name: CI\non:\n pull_request:\njobs:\n ci:\n uses: x\n ci-required:\n needs: [ci]\n`
|
|
84
|
+
)]]);
|
|
85
|
+
const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
|
|
86
|
+
assert.deepEqual(warnings, []);
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("warnOnNonCanonicalCallerNaming: warns (does not throw) when the caller file is missing", () => {
|
|
92
|
+
withTempWorkflowsDir({ "quality.yml": "name: quality\non:\njobs:\n quality:\n" }, (dir) => {
|
|
93
|
+
const workflowFiles = ["quality.yml"];
|
|
94
|
+
const workflowJobMap = new Map([["quality.yml", new Set(["quality"])]]);
|
|
95
|
+
const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
|
|
96
|
+
assert.equal(warnings.length, 1);
|
|
97
|
+
assert.match(warnings[0], new RegExp(`no "${CANONICAL_CALLER.file}" file found`));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("warnOnNonCanonicalCallerNaming: warns on a non-canonical display name (athportal shape)", () => {
|
|
102
|
+
withTempWorkflowsDir(
|
|
103
|
+
{ "ci.yml": `name: quality\non:\njobs:\n ci:\n uses: x\n` },
|
|
104
|
+
(dir) => {
|
|
105
|
+
const workflowFiles = ["ci.yml"];
|
|
106
|
+
const workflowJobMap = new Map([["ci.yml", new Set(["ci"])]]);
|
|
107
|
+
const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
|
|
108
|
+
assert.equal(warnings.length, 1);
|
|
109
|
+
assert.match(warnings[0], /display name is "quality"/);
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("warnOnNonCanonicalCallerNaming: warns on a non-canonical job id (swarm-os shape)", () => {
|
|
115
|
+
withTempWorkflowsDir(
|
|
116
|
+
{ "ci.yml": `name: CI\non:\njobs:\n quality:\n uses: x\n ci-required:\n needs: [quality]\n` },
|
|
117
|
+
(dir) => {
|
|
118
|
+
const workflowFiles = ["ci.yml"];
|
|
119
|
+
const workflowJobMap = new Map([["ci.yml", new Set(["quality", "ci-required"])]]);
|
|
120
|
+
const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
|
|
121
|
+
assert.equal(warnings.length, 1);
|
|
122
|
+
assert.match(warnings[0], /has no "ci" job id/);
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("warnOnNonCanonicalCallerNaming: warns on both display name and job id together", () => {
|
|
128
|
+
withTempWorkflowsDir(
|
|
129
|
+
{ "ci.yml": `name: CI (PR)\non:\njobs:\n build:\n uses: x\n` },
|
|
130
|
+
(dir) => {
|
|
131
|
+
const workflowFiles = ["ci.yml"];
|
|
132
|
+
const workflowJobMap = new Map([["ci.yml", new Set(["build"])]]);
|
|
133
|
+
const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
|
|
134
|
+
assert.equal(warnings.length, 2);
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
});
|