mandrel-platform 1.4.2 → 1.5.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 +2 -2
- package/scripts/apply-uptime-monitors.mjs +6 -2
- package/scripts/check-affected-mode.test.mjs +54 -1
- package/scripts/check-coverage-threshold.test.mjs +1 -1
- package/scripts/check-destructive-migration.mjs +6 -2
- package/scripts/check-osv-scan-mode.test.mjs +2 -2
- package/scripts/check-pin-drift.mjs +7 -5
- package/scripts/check-repo-settings.mjs +6 -4
- package/scripts/check-ruleset.mjs +6 -4
- package/scripts/check-runner-health.mjs +6 -4
- package/scripts/check-runner-runs-on.test.mjs +153 -0
- package/scripts/check-toolchain-cache-default.test.mjs +6 -138
- package/scripts/check-workflow-gh-flags.mjs +6 -2
- package/scripts/check-workflow-platform-checkout.mjs +319 -0
- package/scripts/check-workflow-platform-checkout.test.mjs +342 -0
- package/scripts/check-workflow-portability.mjs +39 -0
- package/scripts/check-workflow-portability.test.mjs +66 -0
- package/scripts/check-wrangler-baseline.mjs +126 -8
- package/scripts/check-wrangler-baseline.test.mjs +258 -1
- package/scripts/deploy-boot-smoke.mjs +1 -1
- package/scripts/deploy-worker-secrets.mjs +1 -1
- package/scripts/lib/actions-expression.mjs +271 -0
- package/scripts/lib/actions-expression.test.mjs +83 -0
- package/scripts/lib/entry-guard.mjs +111 -0
- package/scripts/lib/entry-guard.test.mjs +179 -0
- package/scripts/platform-repair.mjs +6 -4
- package/scripts/track-issue.test.mjs +280 -0
- package/scripts/update-semgrep-rules.mjs +6 -4
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-workflow-platform-checkout.mjs — static lint for the side-checkouts a
|
|
4
|
+
* reusable workflow makes of THIS repository.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* ---------------
|
|
8
|
+
* A reusable workflow that runs `scripts/*.mjs` has to fetch them from
|
|
9
|
+
* mandrel-platform itself, pinned to the exact commit the caller's
|
|
10
|
+
* `uses: …@<ref>` pin resolved to. Two silent failure modes made that pin
|
|
11
|
+
* decorative for the whole life of the mechanism (Story #415):
|
|
12
|
+
*
|
|
13
|
+
* 1. The `ref:` expression read `job_workflow_sha` off the `github` context.
|
|
14
|
+
* That is an OIDC token CLAIM, not a context property, so it evaluated to
|
|
15
|
+
* the EMPTY STRING — with or without the `fromJSON(toJSON(github))`
|
|
16
|
+
* escape hatch that was added to silence actionlint. `actions/checkout`
|
|
17
|
+
* omits an empty input and falls back to the default branch, so every
|
|
18
|
+
* consumer ran platform scripts from `main` regardless of what it pinned.
|
|
19
|
+
* Nothing was red: the wrong code simply ran.
|
|
20
|
+
*
|
|
21
|
+
* 2. `sparse-checkout-cone-mode: false` makes a sparse list EXHAUSTIVE. A
|
|
22
|
+
* list naming `scripts/foo.mjs` and nothing else fetches that one file —
|
|
23
|
+
* not the `scripts/lib/` helpers it imports. The first time a listed
|
|
24
|
+
* script grew a `./lib/*` import, every consumer went red instantly with
|
|
25
|
+
* ERR_MODULE_NOT_FOUND, with no consumer-side change to revert.
|
|
26
|
+
*
|
|
27
|
+
* Both are invisible to actionlint, shellcheck and every unit test, because
|
|
28
|
+
* both are about what a correct-looking workflow RESOLVES TO at runtime. This
|
|
29
|
+
* lint shifts them left into `ci-required`.
|
|
30
|
+
*
|
|
31
|
+
* RULES
|
|
32
|
+
* 1. dead-token — the string `job_workflow_sha` must not appear anywhere in
|
|
33
|
+
* a workflow file, comments included, so it cannot be copied forward.
|
|
34
|
+
* 2. resolved-ref — a checkout of `dsj1984/mandrel-platform` must take its
|
|
35
|
+
* `ref:` from a step output (`steps.<id>.outputs.sha`), never from a raw
|
|
36
|
+
* context expression that can silently evaluate to empty.
|
|
37
|
+
* 3. fail-closed — that step must exist in the same job, read
|
|
38
|
+
* `job.workflow_sha`, and assert a 40-hex value before emitting it.
|
|
39
|
+
* 4. guard-parity — the resolve step must run whenever the checkout does: it
|
|
40
|
+
* is either unguarded (so it always runs, covering every checkout in the
|
|
41
|
+
* job) or carries the checkout's exact `if:`. If the two can diverge, the
|
|
42
|
+
* resolve step skips while the checkout runs, and `actions/checkout` gets
|
|
43
|
+
* an empty ref again — the original bug, exactly.
|
|
44
|
+
* 5. module-graph — a sparse list naming a `scripts/*.mjs` file must also
|
|
45
|
+
* name `scripts/lib/`, so a script arrives with its module graph.
|
|
46
|
+
*
|
|
47
|
+
* SCOPE: `.github/workflows/*.yml` + `templates/workflows/*.yml`.
|
|
48
|
+
* Exit 0 when clean, 1 when any violation is found (prints file:line).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
52
|
+
import { join } from 'node:path';
|
|
53
|
+
|
|
54
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
55
|
+
|
|
56
|
+
const WORKFLOW_DIRS = ['.github/workflows', 'templates/workflows'];
|
|
57
|
+
|
|
58
|
+
/** The repository a platform side-checkout targets. */
|
|
59
|
+
const PLATFORM_REPO = 'dsj1984/mandrel-platform';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Split a workflow into steps, resolving YAML anchors so an aliased step
|
|
63
|
+
* (`- *checkout-range`) is analyzed as the step it stands for. Anchors are
|
|
64
|
+
* document-global in YAML, so they are collected over the whole file before
|
|
65
|
+
* any job is walked.
|
|
66
|
+
*
|
|
67
|
+
* Returns `{ anchors, jobs }` where `jobs` is a Map of job name →
|
|
68
|
+
* `{ line, steps: [{ line, text, aliasOf }] }`. `line` is always the line in
|
|
69
|
+
* the ORIGINAL file, so a finding points at real source even when the step
|
|
70
|
+
* came in through an alias.
|
|
71
|
+
*/
|
|
72
|
+
export function parseWorkflow(source) {
|
|
73
|
+
const lines = source.split('\n');
|
|
74
|
+
const isStepStart = (l) => /^ {6}- /.test(l);
|
|
75
|
+
|
|
76
|
+
// Pass 1 — anchor definitions (` - &name`).
|
|
77
|
+
const anchors = new Map();
|
|
78
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
79
|
+
const m = lines[i].match(/^ {6}- &([A-Za-z0-9_-]+)\s*$/);
|
|
80
|
+
if (!m) continue;
|
|
81
|
+
const body = [];
|
|
82
|
+
for (let j = i + 1; j < lines.length && !isStepStart(lines[j]); j += 1) body.push(lines[j]);
|
|
83
|
+
anchors.set(m[1], body.join('\n'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pass 2 — jobs and their steps. Only 2-space keys UNDER the top-level
|
|
87
|
+
// `jobs:` mapping are jobs — `on:` has 2-space children too (`workflow_call:`),
|
|
88
|
+
// and treating one as a job would invent a step-less job the rules then walk.
|
|
89
|
+
const jobs = new Map();
|
|
90
|
+
let currentJob = null;
|
|
91
|
+
let inSteps = false;
|
|
92
|
+
let inJobs = false;
|
|
93
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
if (/^\S/.test(line)) {
|
|
96
|
+
inJobs = /^jobs:\s*$/.test(line);
|
|
97
|
+
currentJob = null;
|
|
98
|
+
inSteps = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!inJobs) continue;
|
|
102
|
+
const jobMatch = line.match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
|
|
103
|
+
if (jobMatch) {
|
|
104
|
+
currentJob = { name: jobMatch[1], line: i + 1, steps: [] };
|
|
105
|
+
jobs.set(`${jobMatch[1]}@${i + 1}`, currentJob);
|
|
106
|
+
inSteps = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!currentJob) continue;
|
|
110
|
+
if (/^ {4}steps:\s*$/.test(line)) {
|
|
111
|
+
inSteps = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!inSteps || !isStepStart(line)) continue;
|
|
115
|
+
|
|
116
|
+
const alias = line.match(/^ {6}- \*([A-Za-z0-9_-]+)\s*$/);
|
|
117
|
+
if (alias) {
|
|
118
|
+
currentJob.steps.push({
|
|
119
|
+
line: i + 1,
|
|
120
|
+
text: anchors.get(alias[1]) ?? '',
|
|
121
|
+
aliasOf: alias[1],
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const body = [line];
|
|
126
|
+
for (let j = i + 1; j < lines.length && !isStepStart(lines[j]); j += 1) {
|
|
127
|
+
if (/^ {0,4}\S/.test(lines[j]) && lines[j].trim() !== '') break;
|
|
128
|
+
body.push(lines[j]);
|
|
129
|
+
}
|
|
130
|
+
currentJob.steps.push({ line: i + 1, text: body.join('\n'), aliasOf: null });
|
|
131
|
+
}
|
|
132
|
+
return { anchors, jobs };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Strip YAML comment lines so prose never satisfies (or trips) a rule. */
|
|
136
|
+
function withoutComments(text) {
|
|
137
|
+
return text
|
|
138
|
+
.split('\n')
|
|
139
|
+
.filter((l) => !/^\s*#/.test(l))
|
|
140
|
+
.join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The `if:` guard of a step, or `null`. Normalized for comparison. */
|
|
144
|
+
export function stepGuard(text) {
|
|
145
|
+
const m = withoutComments(text).match(/^\s*if:\s*(.+?)\s*$/m);
|
|
146
|
+
return m ? m[1] : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The entries of a step's `sparse-checkout:` block scalar. */
|
|
150
|
+
export function sparseEntries(text) {
|
|
151
|
+
const lines = withoutComments(text).split('\n');
|
|
152
|
+
const start = lines.findIndex((l) => /^\s*sparse-checkout:\s*\|\s*$/.test(l));
|
|
153
|
+
if (start === -1) return null;
|
|
154
|
+
const indent = lines[start].match(/^\s*/)[0].length;
|
|
155
|
+
const out = [];
|
|
156
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
157
|
+
if (lines[i].trim() === '') continue;
|
|
158
|
+
if (lines[i].match(/^\s*/)[0].length <= indent) break;
|
|
159
|
+
out.push(lines[i].trim());
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** True when a step is an `actions/checkout` of the platform repo. */
|
|
165
|
+
function isPlatformCheckout(text) {
|
|
166
|
+
const body = withoutComments(text);
|
|
167
|
+
return /uses:\s*actions\/checkout@/.test(body) && body.includes(`repository: ${PLATFORM_REPO}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A resolve step: reads `job.workflow_sha` AND exports it to `$GITHUB_OUTPUT`
|
|
172
|
+
* for a later checkout to consume. Reading the value for some other purpose —
|
|
173
|
+
* `deploy-summary` echoes it into the job summary — is not a resolve step and
|
|
174
|
+
* needs no `id:`.
|
|
175
|
+
*/
|
|
176
|
+
function resolveStepId(text) {
|
|
177
|
+
const body = withoutComments(text);
|
|
178
|
+
if (!body.includes('job.workflow_sha') || !body.includes('GITHUB_OUTPUT')) return null;
|
|
179
|
+
const m = body.match(/^\s*id:\s*([A-Za-z0-9_-]+)\s*$/m);
|
|
180
|
+
return m ? m[1] : '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Does a resolve step actually fail closed on a non-40-hex value? */
|
|
184
|
+
function assertsFullSha(text) {
|
|
185
|
+
const body = withoutComments(text);
|
|
186
|
+
return /\[0-9a-f\]\{40\}/.test(body) && /exit 1/.test(body);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function lintWorkflow(path, source) {
|
|
190
|
+
const findings = [];
|
|
191
|
+
const add = (line, rule, detail) => findings.push({ path, line, rule, detail });
|
|
192
|
+
|
|
193
|
+
// Rule 1 — the dead token, anywhere in the file including comments.
|
|
194
|
+
source.split('\n').forEach((line, i) => {
|
|
195
|
+
if (line.includes('job_workflow_sha')) {
|
|
196
|
+
add(
|
|
197
|
+
i + 1,
|
|
198
|
+
'dead-token',
|
|
199
|
+
'`job_workflow_sha` is an OIDC token claim, not a github-context property — it always evaluates to the empty string. Use the `job` context (`job.workflow_sha`).',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const { jobs } = parseWorkflow(source);
|
|
205
|
+
for (const job of jobs.values()) {
|
|
206
|
+
const resolvers = new Map();
|
|
207
|
+
for (const step of job.steps) {
|
|
208
|
+
const id = resolveStepId(step.text);
|
|
209
|
+
if (id === null) continue;
|
|
210
|
+
if (id === '') {
|
|
211
|
+
add(step.line, 'fail-closed', 'a step reading `job.workflow_sha` has no `id:`, so no checkout can consume it.');
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
resolvers.set(id, step);
|
|
215
|
+
if (!assertsFullSha(step.text)) {
|
|
216
|
+
add(
|
|
217
|
+
step.line,
|
|
218
|
+
'fail-closed',
|
|
219
|
+
`resolve step \`${id}\` must assert the value matches ^[0-9a-f]{40}$ and \`exit 1\` otherwise — an unresolved ref must stop the job, never fall back to the default branch.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const step of job.steps) {
|
|
225
|
+
if (!isPlatformCheckout(step.text)) continue;
|
|
226
|
+
const body = withoutComments(step.text);
|
|
227
|
+
const ref = body.match(/^\s*ref:\s*(.+?)\s*$/m);
|
|
228
|
+
|
|
229
|
+
// Rule 2 — the ref must come from a resolve step's output.
|
|
230
|
+
const viaStep = ref && ref[1].match(/\$\{\{\s*steps\.([A-Za-z0-9_-]+)\.outputs\.sha\s*\}\}/);
|
|
231
|
+
if (!viaStep) {
|
|
232
|
+
add(
|
|
233
|
+
step.line,
|
|
234
|
+
'resolved-ref',
|
|
235
|
+
`platform checkout must set \`ref: \${{ steps.<id>.outputs.sha }}\` from a fail-closed resolve step; found ${ref ? `\`${ref[1]}\`` : 'no `ref:` at all'}. An empty ref makes actions/checkout silently use the default branch.`,
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
const resolver = resolvers.get(viaStep[1]);
|
|
239
|
+
if (!resolver) {
|
|
240
|
+
add(
|
|
241
|
+
step.line,
|
|
242
|
+
'fail-closed',
|
|
243
|
+
`\`ref:\` reads \`steps.${viaStep[1]}.outputs.sha\` but no step in this job resolves \`job.workflow_sha\` under that id.`,
|
|
244
|
+
);
|
|
245
|
+
} else if (
|
|
246
|
+
stepGuard(resolver.text) !== null &&
|
|
247
|
+
stepGuard(resolver.text) !== stepGuard(step.text)
|
|
248
|
+
) {
|
|
249
|
+
// Rule 4 — the resolve step must run WHENEVER the checkout runs. An
|
|
250
|
+
// UNGUARDED resolve step always does, so it safely covers any number
|
|
251
|
+
// of differently-guarded checkouts in the same job. A guarded one
|
|
252
|
+
// only covers a checkout carrying the identical guard: if the two
|
|
253
|
+
// conditions can diverge, the resolve step skips while the checkout
|
|
254
|
+
// runs, `steps.<id>.outputs.sha` is empty, and actions/checkout is
|
|
255
|
+
// back to silently using the default branch — the original bug.
|
|
256
|
+
add(
|
|
257
|
+
step.line,
|
|
258
|
+
'guard-parity',
|
|
259
|
+
`checkout \`if:\` (${stepGuard(step.text) ?? 'none'}) differs from resolve step \`${viaStep[1]}\` \`if:\` (${stepGuard(resolver.text)}). A guarded resolve step must carry the checkout's exact guard, or leave itself unguarded so it always runs.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Rule 5 — a script travels with its module graph.
|
|
265
|
+
const entries = sparseEntries(step.text);
|
|
266
|
+
if (!entries) continue;
|
|
267
|
+
const coversAll = entries.some((e) => e === 'scripts' || e === 'scripts/');
|
|
268
|
+
const hasLib = entries.some((e) => e === 'scripts/lib' || e === 'scripts/lib/');
|
|
269
|
+
const scriptEntry = entries.find((e) => /^scripts\/[^/]+\.mjs$/.test(e));
|
|
270
|
+
if (scriptEntry && !coversAll && !hasLib) {
|
|
271
|
+
add(
|
|
272
|
+
step.line,
|
|
273
|
+
'module-graph',
|
|
274
|
+
`sparse-checkout lists \`${scriptEntry}\` but not \`scripts/lib/\`, and \`sparse-checkout-cone-mode: false\` makes the list exhaustive — the script's \`./lib/*\` imports would not be fetched.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return findings;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function collectWorkflowFiles() {
|
|
283
|
+
const files = [];
|
|
284
|
+
for (const dir of WORKFLOW_DIRS) {
|
|
285
|
+
if (!existsSync(dir)) continue;
|
|
286
|
+
for (const name of readdirSync(dir)) {
|
|
287
|
+
if (name.endsWith('.yml') || name.endsWith('.yaml')) files.push(join(dir, name));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return files.sort();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function main() {
|
|
294
|
+
const files = collectWorkflowFiles();
|
|
295
|
+
const findings = [];
|
|
296
|
+
for (const f of files) findings.push(...lintWorkflow(f, readFileSync(f, 'utf8')));
|
|
297
|
+
|
|
298
|
+
if (findings.length === 0) {
|
|
299
|
+
console.log(
|
|
300
|
+
`[check-workflow-platform-checkout] ✓ ${files.length} workflow file(s) — every mandrel-platform side-checkout is pinned, fail-closed, and ships its module graph.`,
|
|
301
|
+
);
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.error(
|
|
306
|
+
`[check-workflow-platform-checkout] ✗ ${findings.length} platform-checkout violation(s):\n`,
|
|
307
|
+
);
|
|
308
|
+
for (const { path, line, rule, detail } of findings) {
|
|
309
|
+
console.error(` ${path}:${line} — [${rule}] ${detail}`);
|
|
310
|
+
}
|
|
311
|
+
console.error(
|
|
312
|
+
'\nThese pass actionlint and every unit test, and go wrong only at RUNTIME — on consumers, not here. Fix before merge.',
|
|
313
|
+
);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
318
|
+
process.exit(main());
|
|
319
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for check-workflow-platform-checkout.mjs (Story #415).
|
|
3
|
+
*
|
|
4
|
+
* The lint exists because the defect class it guards is invisible to every
|
|
5
|
+
* other gate: an empty `ref:` is valid YAML, valid actionlint, and green in CI
|
|
6
|
+
* — it goes wrong only at runtime, on a consumer. So these tests assert both
|
|
7
|
+
* directions: each violation shape IS caught, and each legitimate shape is
|
|
8
|
+
* NOT, because a lint that over-fires on a sound pattern gets suppressed and
|
|
9
|
+
* then guards nothing.
|
|
10
|
+
*/
|
|
11
|
+
import test from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
import { lintWorkflow, parseWorkflow, sparseEntries, stepGuard } from './check-workflow-platform-checkout.mjs';
|
|
18
|
+
|
|
19
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
/** A fail-closed resolve step, as the real workflows carry it. */
|
|
22
|
+
const RESOLVE = (id, cond) =>
|
|
23
|
+
` - name: Resolve platform ref
|
|
24
|
+
${cond ? ` if: ${cond}\n` : ''} id: ${id}
|
|
25
|
+
shell: bash
|
|
26
|
+
env:
|
|
27
|
+
PLATFORM_SHA: \${{ job.workflow_sha }}
|
|
28
|
+
run: |
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
if [[ ! "\${PLATFORM_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
|
|
31
|
+
echo "::error::unresolved"
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/** A platform checkout consuming a resolve step's output. */
|
|
38
|
+
const CHECKOUT = ({ ref = '${{ steps.platform-ref.outputs.sha }}', cond = null, sparse = ['scripts'] }) =>
|
|
39
|
+
` - name: Checkout platform
|
|
40
|
+
${cond ? ` if: ${cond}\n` : ''} uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
41
|
+
with:
|
|
42
|
+
repository: dsj1984/mandrel-platform
|
|
43
|
+
ref: ${ref}
|
|
44
|
+
sparse-checkout: |
|
|
45
|
+
${sparse.map((s) => ` ${s}`).join('\n')}
|
|
46
|
+
sparse-checkout-cone-mode: false
|
|
47
|
+
path: _platform
|
|
48
|
+
persist-credentials: false
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const workflow = (steps) => `name: t
|
|
52
|
+
on:
|
|
53
|
+
workflow_call:
|
|
54
|
+
jobs:
|
|
55
|
+
build:
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
steps:
|
|
58
|
+
${steps}`;
|
|
59
|
+
|
|
60
|
+
const rules = (findings) => findings.map((f) => f.rule).sort();
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// The clean shape
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
test('a pinned, fail-closed, module-graph-complete checkout is clean', () => {
|
|
67
|
+
const src = workflow(RESOLVE('platform-ref', null) + '\n' + CHECKOUT({}));
|
|
68
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Rule 1 — dead-token
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
test('the dead job_workflow_sha token is flagged anywhere in the file', () => {
|
|
76
|
+
const src = workflow(
|
|
77
|
+
RESOLVE('platform-ref', null) + '\n' + CHECKOUT({}),
|
|
78
|
+
).replace('name: t', '# note: job_workflow_sha used to live here\nname: t');
|
|
79
|
+
const found = lintWorkflow('t.yml', src);
|
|
80
|
+
assert.deepEqual(rules(found), ['dead-token']);
|
|
81
|
+
assert.match(found[0].detail, /OIDC token claim/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the dead token is flagged even inside a live ref expression', () => {
|
|
85
|
+
const src = workflow(CHECKOUT({ ref: '${{ fromJSON(toJSON(github)).job_workflow_sha }}' }));
|
|
86
|
+
assert.ok(rules(lintWorkflow('t.yml', src)).includes('dead-token'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Rule 2 — resolved-ref
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
test('a raw context expression as ref: is rejected', () => {
|
|
94
|
+
// The exact shape that shipped the outage — note it is otherwise valid YAML.
|
|
95
|
+
const src = workflow(CHECKOUT({ ref: '${{ github.sha }}' }));
|
|
96
|
+
const found = lintWorkflow('t.yml', src);
|
|
97
|
+
assert.ok(rules(found).includes('resolved-ref'));
|
|
98
|
+
assert.match(found.find((f) => f.rule === 'resolved-ref').detail, /silently use the default branch/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a platform checkout with no ref: at all is rejected', () => {
|
|
102
|
+
const src = workflow(
|
|
103
|
+
` - name: Checkout platform
|
|
104
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
105
|
+
with:
|
|
106
|
+
repository: dsj1984/mandrel-platform
|
|
107
|
+
path: _platform
|
|
108
|
+
`,
|
|
109
|
+
);
|
|
110
|
+
const found = lintWorkflow('t.yml', src);
|
|
111
|
+
assert.ok(rules(found).includes('resolved-ref'));
|
|
112
|
+
assert.match(found[0].detail, /no `ref:` at all/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a checkout of some OTHER repository is not this lint’s business', () => {
|
|
116
|
+
const src = workflow(
|
|
117
|
+
` - name: Checkout elsewhere
|
|
118
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
119
|
+
with:
|
|
120
|
+
repository: someone/else
|
|
121
|
+
ref: \${{ github.sha }}
|
|
122
|
+
`,
|
|
123
|
+
);
|
|
124
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Rule 3 — fail-closed
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
test('a resolve step that does not assert 40-hex is rejected', () => {
|
|
132
|
+
const weak = ` - name: Resolve platform ref
|
|
133
|
+
id: platform-ref
|
|
134
|
+
shell: bash
|
|
135
|
+
env:
|
|
136
|
+
PLATFORM_SHA: \${{ job.workflow_sha }}
|
|
137
|
+
run: echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
|
|
138
|
+
`;
|
|
139
|
+
const found = lintWorkflow('t.yml', workflow(weak + '\n' + CHECKOUT({})));
|
|
140
|
+
assert.ok(rules(found).includes('fail-closed'));
|
|
141
|
+
assert.match(found.find((f) => f.rule === 'fail-closed').detail, /never fall back to the default branch/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a ref: pointing at a step id that resolves nothing is rejected', () => {
|
|
145
|
+
const src = workflow(CHECKOUT({ ref: '${{ steps.nope.outputs.sha }}' }));
|
|
146
|
+
const found = lintWorkflow('t.yml', src);
|
|
147
|
+
assert.ok(rules(found).includes('fail-closed'));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a step reading job.workflow_sha for output but carrying no id is rejected', () => {
|
|
151
|
+
const noId = ` - name: Resolve platform ref
|
|
152
|
+
shell: bash
|
|
153
|
+
env:
|
|
154
|
+
PLATFORM_SHA: \${{ job.workflow_sha }}
|
|
155
|
+
run: |
|
|
156
|
+
if [[ ! "\${PLATFORM_SHA}" =~ ^[0-9a-f]{40}$ ]]; then exit 1; fi
|
|
157
|
+
echo "sha=\${PLATFORM_SHA}" >> "$GITHUB_OUTPUT"
|
|
158
|
+
`;
|
|
159
|
+
const found = lintWorkflow('t.yml', workflow(noId));
|
|
160
|
+
assert.ok(rules(found).includes('fail-closed'));
|
|
161
|
+
assert.match(found[0].detail, /no `id:`/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('reading job.workflow_sha for a job summary is not a resolve step', () => {
|
|
165
|
+
// deploy-summary echoes the resolved SHA without exporting it; requiring an
|
|
166
|
+
// `id:` there would be noise, so this must NOT fire.
|
|
167
|
+
const summary = ` - name: Emit resolved platform-ref summary
|
|
168
|
+
shell: bash
|
|
169
|
+
run: echo "| SHA | \\\`\${SHA}\\\` |" >> "$GITHUB_STEP_SUMMARY"
|
|
170
|
+
env:
|
|
171
|
+
SHA: \${{ job.workflow_sha }}
|
|
172
|
+
`;
|
|
173
|
+
assert.deepEqual(lintWorkflow('t.yml', workflow(summary)), []);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Rule 4 — guard-parity
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
test('a guarded resolve step paired with a differently-guarded checkout is rejected', () => {
|
|
181
|
+
const src = workflow(
|
|
182
|
+
RESOLVE('platform-ref', "${{ inputs.a }}") + '\n' + CHECKOUT({ cond: '${{ inputs.b }}' }),
|
|
183
|
+
);
|
|
184
|
+
const found = lintWorkflow('t.yml', src);
|
|
185
|
+
assert.ok(rules(found).includes('guard-parity'));
|
|
186
|
+
assert.match(found.find((f) => f.rule === 'guard-parity').detail, /or leave itself unguarded/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('matching guards on resolve and checkout are clean', () => {
|
|
190
|
+
const src = workflow(
|
|
191
|
+
RESOLVE('platform-ref', '${{ inputs.a }}') + '\n' + CHECKOUT({ cond: '${{ inputs.a }}' }),
|
|
192
|
+
);
|
|
193
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('an UNGUARDED resolve step covers any guarded checkout', () => {
|
|
197
|
+
// The security tier's real shape: one unconditional resolve serving three
|
|
198
|
+
// checkouts with three different `if:` guards. It always runs, so its output
|
|
199
|
+
// is always populated — flagging it would be a false positive.
|
|
200
|
+
const src = workflow(
|
|
201
|
+
RESOLVE('platform-ref', null) +
|
|
202
|
+
'\n' +
|
|
203
|
+
CHECKOUT({ cond: '${{ inputs.enable-sast }}' }) +
|
|
204
|
+
'\n' +
|
|
205
|
+
CHECKOUT({ cond: "${{ inputs.enable-sast && inputs.semgrep-config == 'vendored' }}" }),
|
|
206
|
+
);
|
|
207
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Rule 5 — module-graph
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
test('a sparse list naming a .mjs script but not scripts/lib/ is rejected', () => {
|
|
215
|
+
// Exactly the uptime-apply shape that took every consumer red.
|
|
216
|
+
const src = workflow(
|
|
217
|
+
RESOLVE('platform-ref', null) +
|
|
218
|
+
'\n' +
|
|
219
|
+
CHECKOUT({ sparse: ['scripts/apply-uptime-monitors.mjs', '.nvmrc'] }),
|
|
220
|
+
);
|
|
221
|
+
const found = lintWorkflow('t.yml', src);
|
|
222
|
+
assert.deepEqual(rules(found), ['module-graph']);
|
|
223
|
+
assert.match(found[0].detail, /scripts\/apply-uptime-monitors\.mjs/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('adding scripts/lib/ to the same list clears it', () => {
|
|
227
|
+
const src = workflow(
|
|
228
|
+
RESOLVE('platform-ref', null) +
|
|
229
|
+
'\n' +
|
|
230
|
+
CHECKOUT({ sparse: ['scripts/apply-uptime-monitors.mjs', 'scripts/lib/', '.nvmrc'] }),
|
|
231
|
+
);
|
|
232
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a bare `scripts` entry already covers the whole tree', () => {
|
|
236
|
+
const src = workflow(RESOLVE('platform-ref', null) + '\n' + CHECKOUT({ sparse: ['scripts'] }));
|
|
237
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a non-.mjs sparse list needs no module graph', () => {
|
|
241
|
+
const src = workflow(
|
|
242
|
+
RESOLVE('platform-ref', null) + '\n' + CHECKOUT({ sparse: ['scripts/resolve-diff-range.sh'] }),
|
|
243
|
+
);
|
|
244
|
+
assert.deepEqual(lintWorkflow('t.yml', src), []);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Parsing seams
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
test('YAML aliases are resolved to the step they stand for', () => {
|
|
252
|
+
// pr-quality.yml reuses `&checkout-range` across five jobs; without anchor
|
|
253
|
+
// expansion the aliasing jobs would look like they had no checkout at all
|
|
254
|
+
// and the lint would pass them vacuously.
|
|
255
|
+
const src = `name: t
|
|
256
|
+
on:
|
|
257
|
+
workflow_call:
|
|
258
|
+
jobs:
|
|
259
|
+
a:
|
|
260
|
+
runs-on: ubuntu-latest
|
|
261
|
+
steps:
|
|
262
|
+
- &co
|
|
263
|
+
name: Checkout platform
|
|
264
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
265
|
+
with:
|
|
266
|
+
repository: dsj1984/mandrel-platform
|
|
267
|
+
ref: \${{ github.sha }}
|
|
268
|
+
b:
|
|
269
|
+
runs-on: ubuntu-latest
|
|
270
|
+
steps:
|
|
271
|
+
- *co
|
|
272
|
+
`;
|
|
273
|
+
const { jobs } = parseWorkflow(src);
|
|
274
|
+
assert.equal(jobs.size, 2);
|
|
275
|
+
const found = lintWorkflow('t.yml', src);
|
|
276
|
+
// Both the anchor site and the alias site are reported, at their own lines.
|
|
277
|
+
assert.deepEqual(rules(found), ['resolved-ref', 'resolved-ref']);
|
|
278
|
+
assert.notEqual(found[0].line, found[1].line);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('sparseEntries reads a block scalar and stops at the next key', () => {
|
|
282
|
+
const text = ` - name: x
|
|
283
|
+
with:
|
|
284
|
+
sparse-checkout: |
|
|
285
|
+
scripts/a.mjs
|
|
286
|
+
scripts/lib/
|
|
287
|
+
sparse-checkout-cone-mode: false
|
|
288
|
+
path: _p
|
|
289
|
+
`;
|
|
290
|
+
assert.deepEqual(sparseEntries(text), ['scripts/a.mjs', 'scripts/lib/']);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('stepGuard ignores commented-out if: lines', () => {
|
|
294
|
+
assert.equal(stepGuard(' - name: x\n # if: ${{ never }}\n run: true\n'), null);
|
|
295
|
+
assert.equal(stepGuard(' - name: x\n if: ${{ inputs.a }}\n'), '${{ inputs.a }}');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// The real tree
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
test('every shipped workflow passes the lint', () => {
|
|
303
|
+
const findings = [];
|
|
304
|
+
for (const dir of ['.github/workflows', 'templates/workflows']) {
|
|
305
|
+
const abs = join(REPO, dir);
|
|
306
|
+
if (!existsSync(abs)) continue;
|
|
307
|
+
for (const name of readdirSync(abs)) {
|
|
308
|
+
if (!name.endsWith('.yml') && !name.endsWith('.yaml')) continue;
|
|
309
|
+
findings.push(...lintWorkflow(join(dir, name), readFileSync(join(abs, name), 'utf8')));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
assert.deepEqual(
|
|
313
|
+
findings,
|
|
314
|
+
[],
|
|
315
|
+
`platform-checkout violations:\n${findings.map((f) => `${f.path}:${f.line} [${f.rule}] ${f.detail}`).join('\n')}`,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('every platform checkout in the shipped workflows is actually pinned', () => {
|
|
320
|
+
// Belt-and-braces over the rule engine: assert the resolved shape directly,
|
|
321
|
+
// so a future refactor of the lint cannot quietly stop covering the tree.
|
|
322
|
+
const dir = join(REPO, '.github/workflows');
|
|
323
|
+
let checkouts = 0;
|
|
324
|
+
for (const name of readdirSync(dir)) {
|
|
325
|
+
if (!name.endsWith('.yml')) continue;
|
|
326
|
+
const src = readFileSync(join(dir, name), 'utf8');
|
|
327
|
+
assert.ok(!src.includes('job_workflow_sha'), `${name} still names the dead OIDC claim`);
|
|
328
|
+
const { jobs } = parseWorkflow(src);
|
|
329
|
+
for (const job of jobs.values()) {
|
|
330
|
+
for (const step of job.steps) {
|
|
331
|
+
if (!step.text.includes('repository: dsj1984/mandrel-platform')) continue;
|
|
332
|
+
checkouts += 1;
|
|
333
|
+
assert.match(
|
|
334
|
+
step.text,
|
|
335
|
+
/ref: \$\{\{ steps\.[A-Za-z0-9_-]+\.outputs\.sha \}\}/,
|
|
336
|
+
`${name}:${step.line} platform checkout is not pinned to a resolve step`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
assert.ok(checkouts >= 10, `expected the known platform checkouts, saw ${checkouts}`);
|
|
342
|
+
});
|