Skip to content

Commit 277f70b

Browse files
committed
Parse workflow/dev.yml as YAML in the gate guard; add node:test coverage
1 parent d05a1a2 commit 277f70b

5 files changed

Lines changed: 361 additions & 334 deletions

File tree

.github/workflows/tests-pr.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,14 @@ jobs:
320320
ci-gate-sync:
321321
name: 'CI gate manifest'
322322
runs-on: ubuntu-latest
323-
timeout-minutes: 5
323+
timeout-minutes: 10
324324
steps:
325325
- uses: actions/checkout@v6
326-
- uses: actions/setup-node@v4
326+
- name: Setup deps
327+
uses: ./.github/actions/setup-cli-deps
327328
with:
328329
node-version: ${{ env.DEFAULT_NODE_VERSION }}
330+
- name: Test the gate checker
331+
run: node --test bin/check-ci-gates.test.js
329332
- name: Check the CI gate manifest matches this workflow
330333
run: node bin/check-ci-gates.js

bin/check-ci-gates.js

Lines changed: 65 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,82 @@
11
// Drift guard: fails if the local CI-gate manifest (bin/ci-gates.js) falls out of
22
// 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.
55
import {readFileSync} from 'node:fs'
6-
import {fileURLToPath} from 'node:url'
6+
import {fileURLToPath, pathToFileURL} from 'node:url'
77
import {dirname, join} from 'node:path'
88

9+
import {parse as parseYaml} from 'yaml'
10+
911
import {MANIFEST_JOB_IDS} from './ci-gates.js'
1012

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 = []
1317

14-
const problems = []
18+
const wf = parseYaml(workflow) ?? {}
19+
const dev = parseYaml(devYml) ?? {}
1520

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))
2027

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+
}
2537

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
3844

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')
4652

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+
}
5159

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}
5761
}
5862

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+
})
6473

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+
}

bin/check-ci-gates.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from 'node:assert/strict'
2+
import test from 'node:test'
3+
4+
import {findProblems} from './check-ci-gates.js'
5+
6+
const workflow = (jobIds, {node = '26.1.0', pnpm = '10.11.1'} = {}) =>
7+
`name: tests\non: pull_request\nenv:\n DEFAULT_NODE_VERSION: '${node}'\n PNPM_VERSION: '${pnpm}'\njobs:\n` +
8+
jobIds.map((id) => ` ${id}:\n runs-on: ubuntu-latest\n steps: []`).join('\n') +
9+
'\n'
10+
11+
const devYml = ({node = '26.1.0', pnpm = '10.11.1'} = {}) =>
12+
`name: cli\nup:\n - node:\n version: ${node}\n package_manager: pnpm@${pnpm}\n - packages:\n - jq\n`
13+
14+
test('in sync: no problems', () => {
15+
const {problems} = findProblems({workflow: workflow(['a', 'b']), devYml: devYml(), manifestJobIds: ['a', 'b']})
16+
assert.deepEqual(problems, [])
17+
})
18+
19+
test('workflow job missing from the manifest', () => {
20+
const {problems} = findProblems({workflow: workflow(['a', 'b', 'c']), devYml: devYml(), manifestJobIds: ['a', 'b']})
21+
assert.match(problems.join('\n'), /not classified.*\bc\b/)
22+
})
23+
24+
test('manifest lists a job absent from the workflow', () => {
25+
const {problems} = findProblems({workflow: workflow(['a']), devYml: devYml(), manifestJobIds: ['a', 'b']})
26+
assert.match(problems.join('\n'), /absent from tests-pr\.yml.*\bb\b/)
27+
})
28+
29+
test('node version mismatch is detected', () => {
30+
const {problems} = findProblems({workflow: workflow(['a'], {node: '25.0.0'}), devYml: devYml({node: '26.1.0'}), manifestJobIds: ['a']})
31+
assert.match(problems.join('\n'), /Node version mismatch/)
32+
})
33+
34+
test('pnpm version mismatch is detected', () => {
35+
const {problems} = findProblems({workflow: workflow(['a']), devYml: devYml({pnpm: '9.0.0'}), manifestJobIds: ['a']})
36+
assert.match(problems.join('\n'), /pnpm version mismatch/)
37+
})
38+
39+
test('a missing version pin is reported, not silently passed', () => {
40+
const noEnv = `name: t\non: pull_request\njobs:\n a:\n runs-on: ubuntu-latest\n steps: []\n`
41+
const {problems} = findProblems({workflow: noEnv, devYml: devYml(), manifestJobIds: ['a']})
42+
assert.match(problems.join('\n'), /Could not read DEFAULT_NODE_VERSION/)
43+
})
44+
45+
test('structural parse survives formatting a regex would miss', () => {
46+
// Inline comment after the job key and a non-2-space step indent — both break
47+
// a line-anchored regex but are irrelevant to a YAML parse.
48+
const wf = `env:\n DEFAULT_NODE_VERSION: '26.1.0'\n PNPM_VERSION: '10.11.1'\njobs:\n build: # freshness gate\n runs-on: ubuntu-latest\n steps: []\n`
49+
const {problems, workflowJobIds} = findProblems({workflow: wf, devYml: devYml(), manifestJobIds: ['build']})
50+
assert.deepEqual(workflowJobIds, ['build'])
51+
assert.deepEqual(problems, [])
52+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"ts-node": "^10.9.1",
8686
"typescript": "5.9.3",
8787
"vitest": "^3.1.4",
88+
"yaml": "2.8.3",
8889
"zod": "3.24.4"
8990
},
9091
"workspaces": {

0 commit comments

Comments
 (0)