mandrel-platform 1.4.2 → 1.5.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 +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-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-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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mandrel": "^2.
|
|
47
|
+
"mandrel": "^2.35.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
import { readFileSync } from "node:fs";
|
|
62
62
|
import { resolve } from "node:path";
|
|
63
63
|
|
|
64
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
65
|
+
|
|
64
66
|
// ---------------------------------------------------------------------------
|
|
65
67
|
// Monitor config schema (pure validation — no I/O)
|
|
66
68
|
// ---------------------------------------------------------------------------
|
|
@@ -436,7 +438,9 @@ async function main() {
|
|
|
436
438
|
}
|
|
437
439
|
}
|
|
438
440
|
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
442
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
443
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
444
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
441
445
|
main();
|
|
442
446
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `scripts/resolve-diff-range.sh` has solid unit coverage
|
|
8
8
|
* (`resolve-diff-range.test.mjs`), but the affected-mode *wiring* inside the
|
|
9
9
|
* reusable workflow was only covered indirectly by anchor-expansion checks.
|
|
10
|
-
* This suite pins the
|
|
10
|
+
* This suite pins the four invariants that keep the affected-mode design
|
|
11
11
|
* correct, modelled on the sibling `check-ci-required-aggregator.test.mjs`
|
|
12
12
|
* (read the real workflow, extract blocks by indentation, assert):
|
|
13
13
|
*
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
* `affected-base` override is rejected before the `$GITHUB_ENV` write, so
|
|
25
25
|
* it can never inject a second env line; a single-line override exports
|
|
26
26
|
* exactly `TURBO_SCM_BASE` / `TURBO_SCM_HEAD` and nothing else.
|
|
27
|
+
* 4. DOCUMENTED PRECONDITION (Story #413) — neither `.github/workflows/
|
|
28
|
+
* pr-quality.yml` nor `docs/reusable-workflows.md` claims affected mode
|
|
29
|
+
* can never miss a task (true only of an UNRESOLVABLE base), and both
|
|
30
|
+
* give the conditional consumer shape `turbo run <tier>
|
|
31
|
+
* ${TURBO_SCM_BASE:+--affected}` — the gate that stops a baked-in
|
|
32
|
+
* `--affected` from scheduling zero tasks on a push-to-`main` run.
|
|
27
33
|
*
|
|
28
34
|
* The `run:` script is executed against real bash with a stubbed
|
|
29
35
|
* `resolve-diff-range.sh` (the sourced derivation), so no git repo is needed;
|
|
@@ -180,3 +186,50 @@ test("single-line affected-base override exports exactly TURBO_SCM_BASE and TURB
|
|
|
180
186
|
const nonEmpty = r.envLines.split("\n").filter((l) => l.trim() !== "");
|
|
181
187
|
assert.equal(nonEmpty.length, 2, `expected exactly two env lines, got: ${JSON.stringify(nonEmpty)}`);
|
|
182
188
|
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// 4. DOCUMENTED PRECONDITION (Story #413) — the `affected` input's safety claim
|
|
192
|
+
//
|
|
193
|
+
// The old prose promised affected mode "can never miss a task", which is true
|
|
194
|
+
// only of the UNRESOLVABLE-base case. A `--affected` baked unconditionally into
|
|
195
|
+
// a consumer's package script hits the resolvable-but-EMPTY case instead: with
|
|
196
|
+
// `affected: false` the TURBO_SCM_* vars are never exported, turbo falls back
|
|
197
|
+
// to its own default base of `main`, and on a push to `main` that range is
|
|
198
|
+
// empty — zero tasks scheduled, exit 0, a silent no-op that reads as a pass.
|
|
199
|
+
// These assertions pin the correction in both consumer-facing surfaces so the
|
|
200
|
+
// absolute claim cannot return and the conditional shape cannot be dropped.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
204
|
+
|
|
205
|
+
/** The safe consumer shape: `--affected` only when a base was exported. */
|
|
206
|
+
const CONDITIONAL_FORM = "${TURBO_SCM_BASE:+--affected}";
|
|
207
|
+
|
|
208
|
+
/** Absolute claims that over-promise the fallback. Retired — must not return. */
|
|
209
|
+
const ABSOLUTE_CLAIMS = ["so this can never miss a task", "never a missed task"];
|
|
210
|
+
|
|
211
|
+
for (const [label, relPath] of [
|
|
212
|
+
["the reusable workflow", WORKFLOW],
|
|
213
|
+
["the consumer documentation", DOCS],
|
|
214
|
+
]) {
|
|
215
|
+
test(`${label} states the affected-mode precondition conditionally, not absolutely`, () => {
|
|
216
|
+
const text = readFileSync(join(repoRoot, relPath), "utf8");
|
|
217
|
+
// Case-insensitive: the claim is retired as a claim, not as a casing.
|
|
218
|
+
const haystack = text.toLowerCase();
|
|
219
|
+
|
|
220
|
+
for (const claim of ABSOLUTE_CLAIMS) {
|
|
221
|
+
assert.ok(
|
|
222
|
+
!haystack.includes(claim),
|
|
223
|
+
`${relPath} still claims "${claim}" — the full-run fallback covers an ` +
|
|
224
|
+
"unresolvable base only, not a base that resolves to an empty range"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
assert.ok(
|
|
229
|
+
text.includes(CONDITIONAL_FORM),
|
|
230
|
+
`${relPath} must recommend \`turbo run <tier> ${CONDITIONAL_FORM}\` — gating ` +
|
|
231
|
+
"the flag on the exported base is what keeps a push-to-`main` run from " +
|
|
232
|
+
"silently scheduling zero tasks"
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -485,7 +485,7 @@ test("formatVerdict renders a skip line for the disabled gate", () => {
|
|
|
485
485
|
//
|
|
486
486
|
// The workflow's "Coverage threshold gate" step no longer embeds a copy of
|
|
487
487
|
// this script (Story #230): it sparse-side-checkouts mandrel-platform at
|
|
488
|
-
// `
|
|
488
|
+
// `job.workflow_sha` into `_mandrel-platform-scripts/` and runs
|
|
489
489
|
// `scripts/check-coverage-threshold.mjs` directly. These tests exercise that
|
|
490
490
|
// exact invocation shape — the real script, run from the side-checkout path,
|
|
491
491
|
// with the workflow's `--threshold` / `--metric` args and the consumer
|
|
@@ -84,6 +84,8 @@
|
|
|
84
84
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
85
85
|
import { resolve } from "node:path";
|
|
86
86
|
|
|
87
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
88
|
+
|
|
87
89
|
// The override acknowledgement label. Documented in docs/reusable-workflows.md.
|
|
88
90
|
export const DEFAULT_OVERRIDE_LABEL = "migration:destructive-ok";
|
|
89
91
|
|
|
@@ -635,7 +637,9 @@ function main() {
|
|
|
635
637
|
process.exit(1);
|
|
636
638
|
}
|
|
637
639
|
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
641
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
642
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
643
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
640
644
|
main();
|
|
641
645
|
}
|
|
@@ -191,8 +191,8 @@ test("the diff-range resolver is side-checked-out from the pinned platform SHA",
|
|
|
191
191
|
assert.match(block, /repository:\s*dsj1984\/mandrel-platform/);
|
|
192
192
|
assert.match(
|
|
193
193
|
block,
|
|
194
|
-
/
|
|
195
|
-
"must resolve THIS repo at the SHA the caller pinned, not a floating ref",
|
|
194
|
+
/ref: \$\{\{ steps\.[A-Za-z0-9_-]+\.outputs\.sha \}\}/,
|
|
195
|
+
"must resolve THIS repo at the SHA the caller pinned, not a floating ref — and via a fail-closed resolve step, so an unresolvable ref stops the job instead of silently checking out the default branch (Story #415)",
|
|
196
196
|
);
|
|
197
197
|
assert.match(
|
|
198
198
|
block,
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
* that has no canonical pin to track at all (`orphan`). A stale literal is
|
|
59
59
|
* a real configuration error and is never suppressed by the
|
|
60
60
|
* `minimumReleaseAge` hold. The fix is to adopt the resolved-ref step
|
|
61
|
-
* summary `deploy-cloudflare.yml` now emits (its `
|
|
61
|
+
* summary `deploy-cloudflare.yml` now emits (its `job.workflow_sha`
|
|
62
62
|
* single source of truth) rather than maintaining the literal by hand.
|
|
63
63
|
*
|
|
64
64
|
* Data-driven: a new consumer is one object in pin-drift-consumers.json.
|
|
@@ -97,6 +97,8 @@ import {
|
|
|
97
97
|
parseSemver,
|
|
98
98
|
} from "./lib/semver-duration.mjs";
|
|
99
99
|
|
|
100
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
101
|
+
|
|
100
102
|
// Re-export the extracted seams so existing importers (platform-repair.mjs,
|
|
101
103
|
// the test suite) keep their `check-pin-drift.mjs` import paths. The canonical
|
|
102
104
|
// homes are scripts/lib/gh-json.mjs and scripts/lib/semver-duration.mjs
|
|
@@ -1114,9 +1116,9 @@ export function runCli({
|
|
|
1114
1116
|
return 0;
|
|
1115
1117
|
}
|
|
1116
1118
|
|
|
1117
|
-
// Direct-invocation guard
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
if (
|
|
1119
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
1120
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
1121
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
1122
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
1121
1123
|
process.exit(runCli());
|
|
1122
1124
|
}
|
|
@@ -42,6 +42,8 @@ import { readFileSync, appendFileSync } from "node:fs";
|
|
|
42
42
|
import { resolve } from "node:path";
|
|
43
43
|
import { execFileSync } from "node:child_process";
|
|
44
44
|
|
|
45
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
46
|
+
|
|
45
47
|
// ---------------------------------------------------------------------------
|
|
46
48
|
// Arg parsing
|
|
47
49
|
// ---------------------------------------------------------------------------
|
|
@@ -355,9 +357,9 @@ export function runCli({
|
|
|
355
357
|
return 0;
|
|
356
358
|
}
|
|
357
359
|
|
|
358
|
-
// Direct-invocation guard
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (
|
|
360
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
361
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
362
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
363
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
362
364
|
process.exit(runCli());
|
|
363
365
|
}
|
|
@@ -64,6 +64,8 @@ import { readFileSync, appendFileSync } from "node:fs";
|
|
|
64
64
|
import { resolve } from "node:path";
|
|
65
65
|
import { execFileSync } from "node:child_process";
|
|
66
66
|
|
|
67
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
68
|
+
|
|
67
69
|
// ---------------------------------------------------------------------------
|
|
68
70
|
// Arg parsing
|
|
69
71
|
// ---------------------------------------------------------------------------
|
|
@@ -427,9 +429,9 @@ export function runCli({
|
|
|
427
429
|
return 0;
|
|
428
430
|
}
|
|
429
431
|
|
|
430
|
-
// Direct-invocation guard
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (
|
|
432
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
433
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
434
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
435
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
434
436
|
process.exit(runCli());
|
|
435
437
|
}
|
|
@@ -55,6 +55,8 @@ import { resolve } from "node:path";
|
|
|
55
55
|
|
|
56
56
|
import { defaultGhRunner, ghApiJson, isNotFound } from "./lib/gh-json.mjs";
|
|
57
57
|
|
|
58
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
59
|
+
|
|
58
60
|
// ---------------------------------------------------------------------------
|
|
59
61
|
// Arg parsing
|
|
60
62
|
// ---------------------------------------------------------------------------
|
|
@@ -461,9 +463,9 @@ export function runCli({
|
|
|
461
463
|
return 0;
|
|
462
464
|
}
|
|
463
465
|
|
|
464
|
-
// Direct-invocation guard
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (
|
|
466
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
467
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
468
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
469
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
468
470
|
process.exit(runCli());
|
|
469
471
|
}
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
36
|
|
|
37
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
38
|
+
|
|
37
39
|
const WORKFLOW_DIRS = ['.github/workflows', 'templates/workflows'];
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -166,7 +168,9 @@ function main() {
|
|
|
166
168
|
return 1;
|
|
167
169
|
}
|
|
168
170
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
172
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
173
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
174
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
171
175
|
process.exit(main());
|
|
172
176
|
}
|
|
@@ -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
|
+
}
|