|
1 | 1 | // Drift guard: fails if the local CI-gate manifest (bin/ci-gates.js) falls out of |
2 | 2 | // sync with .github/workflows/tests-pr.yml, or if the pinned tool versions in |
3 | | -// dev.yml and tests-pr.yml disagree. Runs in CI (ci-gate-sync job) and locally |
4 | | -// (pnpm check-ci-gates, also invoked by pre-ci). |
| 3 | +// dev.yml and tests-pr.yml disagree. Parses the YAML structurally (not by regex) |
| 4 | +// so the guard does not break when the workflow's formatting changes. |
5 | 5 | import {readFileSync} from 'node:fs' |
6 | | -import {fileURLToPath} from 'node:url' |
| 6 | +import {fileURLToPath, pathToFileURL} from 'node:url' |
7 | 7 | import {dirname, join} from 'node:path' |
8 | 8 |
|
| 9 | +import {parse as parseYaml} from 'yaml' |
| 10 | + |
9 | 11 | import {MANIFEST_JOB_IDS} from './ci-gates.js' |
10 | 12 |
|
11 | | -const root = join(dirname(fileURLToPath(import.meta.url)), '..') |
12 | | -const read = (rel) => readFileSync(join(root, rel), 'utf8') |
| 13 | +// Pure and testable: given the two YAML texts and the manifest job ids, return |
| 14 | +// the list of human-readable problems (empty when everything is in sync). |
| 15 | +export function findProblems({workflow, devYml, manifestJobIds}) { |
| 16 | + const problems = [] |
13 | 17 |
|
14 | | -const problems = [] |
| 18 | + const wf = parseYaml(workflow) ?? {} |
| 19 | + const dev = parseYaml(devYml) ?? {} |
15 | 20 |
|
16 | | -// 1. Workflow job ids must exactly match the manifest job ids. |
17 | | -const workflow = read('.github/workflows/tests-pr.yml') |
18 | | -const jobsSection = workflow.slice(workflow.search(/^jobs:/m)) |
19 | | -const workflowJobIds = [...jobsSection.matchAll(/^ {2}([A-Za-z0-9_-]+):\s*$/gm)].map((match) => match[1]) |
| 21 | + // 1. Workflow job ids must exactly match the manifest job ids. |
| 22 | + const workflowJobIds = Object.keys(wf.jobs ?? {}) |
| 23 | + const manifestSet = new Set(manifestJobIds) |
| 24 | + const workflowSet = new Set(workflowJobIds) |
| 25 | + const missingFromManifest = workflowJobIds.filter((id) => !manifestSet.has(id)) |
| 26 | + const staleInManifest = manifestJobIds.filter((id) => !workflowSet.has(id)) |
20 | 27 |
|
21 | | -const manifestSet = new Set(MANIFEST_JOB_IDS) |
22 | | -const workflowSet = new Set(workflowJobIds) |
23 | | -const missingFromManifest = workflowJobIds.filter((id) => !manifestSet.has(id)) |
24 | | -const staleInManifest = MANIFEST_JOB_IDS.filter((id) => !workflowSet.has(id)) |
| 28 | + if (missingFromManifest.length > 0) { |
| 29 | + problems.push( |
| 30 | + `Workflow jobs not classified in bin/ci-gates.js: ${missingFromManifest.join(', ')}.\n` + |
| 31 | + ` Add each to CI_GATES as a 'pre-ci' gate (with a local command) or 'ci-only' (with a reason).`, |
| 32 | + ) |
| 33 | + } |
| 34 | + if (staleInManifest.length > 0) { |
| 35 | + problems.push(`bin/ci-gates.js lists jobs absent from tests-pr.yml: ${staleInManifest.join(', ')}.`) |
| 36 | + } |
25 | 37 |
|
26 | | -if (missingFromManifest.length > 0) { |
27 | | - problems.push( |
28 | | - `Workflow jobs not classified in bin/ci-gates.js: ${missingFromManifest.join(', ')}.\n` + |
29 | | - ` Add each to CI_GATES as a 'pre-ci' gate (with a local command) or 'ci-only' (with a reason).`, |
30 | | - ) |
31 | | -} |
32 | | -if (staleInManifest.length > 0) { |
33 | | - problems.push( |
34 | | - `bin/ci-gates.js lists jobs absent from tests-pr.yml: ${staleInManifest.join(', ')}.\n` + |
35 | | - ` Remove them or fix the job id.`, |
36 | | - ) |
37 | | -} |
| 38 | + // 2. Pinned tool versions must agree between dev.yml and tests-pr.yml. |
| 39 | + const ciNode = wf.env?.DEFAULT_NODE_VERSION |
| 40 | + const ciPnpm = wf.env?.PNPM_VERSION |
| 41 | + const nodeStep = (dev.up ?? []).find((step) => step && typeof step === 'object' && step.node)?.node |
| 42 | + const devNode = nodeStep?.version == null ? undefined : String(nodeStep.version) |
| 43 | + const devPnpm = nodeStep?.package_manager ? String(nodeStep.package_manager).split('@').pop() : undefined |
38 | 44 |
|
39 | | -// 2. Pinned tool versions must agree between dev.yml and tests-pr.yml. |
40 | | -const devYml = read('dev.yml') |
41 | | -const pick = (source, regex, label) => { |
42 | | - const match = source.match(regex) |
43 | | - if (!match) problems.push(`Could not parse ${label}.`) |
44 | | - return match ? match[1] : null |
45 | | -} |
| 45 | + const require = (value, label) => { |
| 46 | + if (value == null || value === '') problems.push(`Could not read ${label}.`) |
| 47 | + } |
| 48 | + require(ciNode, 'DEFAULT_NODE_VERSION from tests-pr.yml') |
| 49 | + require(ciPnpm, 'PNPM_VERSION from tests-pr.yml') |
| 50 | + require(devNode, 'the node version from dev.yml') |
| 51 | + require(devPnpm, 'the pnpm version from dev.yml') |
46 | 52 |
|
47 | | -const ciNode = pick(workflow, /DEFAULT_NODE_VERSION:\s*'([^']+)'/, 'DEFAULT_NODE_VERSION in tests-pr.yml') |
48 | | -const devNode = pick(devYml, /node:\s*\n\s+version:\s*([0-9][\w.-]*)/, 'node version in dev.yml') |
49 | | -const ciPnpm = pick(workflow, /PNPM_VERSION:\s*'([^']+)'/, 'PNPM_VERSION in tests-pr.yml') |
50 | | -const devPnpm = pick(devYml, /package_manager:\s*pnpm@([0-9][\w.-]*)/, 'pnpm version in dev.yml') |
| 53 | + if (ciNode && devNode && String(ciNode) !== devNode) { |
| 54 | + problems.push(`Node version mismatch: dev.yml ${devNode} vs tests-pr.yml DEFAULT_NODE_VERSION ${ciNode}.`) |
| 55 | + } |
| 56 | + if (ciPnpm && devPnpm && String(ciPnpm) !== devPnpm) { |
| 57 | + problems.push(`pnpm version mismatch: dev.yml ${devPnpm} vs tests-pr.yml PNPM_VERSION ${ciPnpm}.`) |
| 58 | + } |
51 | 59 |
|
52 | | -if (ciNode && devNode && ciNode !== devNode) { |
53 | | - problems.push(`Node version mismatch: dev.yml ${devNode} vs tests-pr.yml DEFAULT_NODE_VERSION ${ciNode}.`) |
54 | | -} |
55 | | -if (ciPnpm && devPnpm && ciPnpm !== devPnpm) { |
56 | | - problems.push(`pnpm version mismatch: dev.yml ${devPnpm} vs tests-pr.yml PNPM_VERSION ${ciPnpm}.`) |
| 60 | + return {problems, workflowJobIds, ciNode, ciPnpm} |
57 | 61 | } |
58 | 62 |
|
59 | | -if (problems.length > 0) { |
60 | | - console.error('CI gate manifest is out of sync with the workflow:\n') |
61 | | - for (const problem of problems) console.error(`- ${problem}`) |
62 | | - process.exit(1) |
63 | | -} |
| 63 | +// Run as a CLI when invoked directly (not when imported by a test). |
| 64 | +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 65 | + const root = join(dirname(fileURLToPath(import.meta.url)), '..') |
| 66 | + const read = (rel) => readFileSync(join(root, rel), 'utf8') |
| 67 | + |
| 68 | + const {problems, workflowJobIds, ciNode, ciPnpm} = findProblems({ |
| 69 | + workflow: read('.github/workflows/tests-pr.yml'), |
| 70 | + devYml: read('dev.yml'), |
| 71 | + manifestJobIds: MANIFEST_JOB_IDS, |
| 72 | + }) |
64 | 73 |
|
65 | | -console.log(`CI gate manifest in sync: ${workflowJobIds.length} workflow jobs classified; tool versions match (node ${ciNode}, pnpm ${ciPnpm}).`) |
| 74 | + if (problems.length > 0) { |
| 75 | + console.error('CI gate manifest is out of sync with the workflow:\n') |
| 76 | + for (const problem of problems) console.error(`- ${problem}`) |
| 77 | + process.exit(1) |
| 78 | + } |
| 79 | + console.log( |
| 80 | + `CI gate manifest in sync: ${workflowJobIds.length} workflow jobs classified; tool versions match (node ${ciNode}, pnpm ${ciPnpm}).`, |
| 81 | + ) |
| 82 | +} |
0 commit comments