Skip to content

Commit fb13f3d

Browse files
committed
Harden the CI-gate guard parsing and add node:test coverage
1 parent d05a1a2 commit fb13f3d

3 files changed

Lines changed: 125 additions & 48 deletions

File tree

.github/workflows/tests-pr.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,5 +326,7 @@ jobs:
326326
- uses: actions/setup-node@v4
327327
with:
328328
node-version: ${{ env.DEFAULT_NODE_VERSION }}
329+
- name: Test the gate checker
330+
run: node --test bin/check-ci-gates.test.js
329331
- name: Check the CI gate manifest matches this workflow
330332
run: node bin/check-ci-gates.js

bin/check-ci-gates.js

Lines changed: 69 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,86 @@
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. Kept dependency-free (no YAML library) so the
4+
// CI job can run on bare Node; the parsing below is hardened for the formats these
5+
// two files actually use.
56
import {readFileSync} from 'node:fs'
6-
import {fileURLToPath} from 'node:url'
7+
import {fileURLToPath, pathToFileURL} from 'node:url'
78
import {dirname, join} from 'node:path'
89

910
import {MANIFEST_JOB_IDS} from './ci-gates.js'
1011

11-
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
12-
const read = (rel) => readFileSync(join(root, rel), 'utf8')
12+
// Job ids are the keys directly under `jobs:`. Bound the search to the jobs block
13+
// (up to the next top-level key) and allow a trailing comment after the id. Job
14+
// keys are always bare (their mapping is on following lines), so nested keys —
15+
// indented deeper than 2 spaces — and `key: value` anchors are naturally excluded.
16+
export function parseJobIds(workflow) {
17+
const jobsAt = workflow.search(/^jobs:/m)
18+
if (jobsAt === -1) return []
19+
const afterHeader = workflow.slice(jobsAt).replace(/^jobs:.*\n/, '')
20+
const nextTopLevel = afterHeader.search(/^\S/m)
21+
const block = nextTopLevel === -1 ? afterHeader : afterHeader.slice(0, nextTopLevel)
22+
return [...block.matchAll(/^ {2}([A-Za-z0-9_-]+):[ \t]*(?:#.*)?$/gm)].map((match) => match[1])
23+
}
1324

14-
const problems = []
25+
// Pure and testable: given the two YAML texts and the manifest job ids, return the
26+
// list of human-readable problems (empty when everything is in sync).
27+
export function findProblems({workflow, devYml, manifestJobIds}) {
28+
const problems = []
1529

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])
30+
const workflowJobIds = parseJobIds(workflow)
31+
const manifestSet = new Set(manifestJobIds)
32+
const workflowSet = new Set(workflowJobIds)
33+
const missingFromManifest = workflowJobIds.filter((id) => !manifestSet.has(id))
34+
const staleInManifest = manifestJobIds.filter((id) => !workflowSet.has(id))
2035

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))
36+
if (missingFromManifest.length > 0) {
37+
problems.push(
38+
`Workflow jobs not classified in bin/ci-gates.js: ${missingFromManifest.join(', ')}.\n` +
39+
` Add each to CI_GATES as a 'pre-ci' gate (with a local command) or 'ci-only' (with a reason).`,
40+
)
41+
}
42+
if (staleInManifest.length > 0) {
43+
problems.push(`bin/ci-gates.js lists jobs absent from tests-pr.yml: ${staleInManifest.join(', ')}.`)
44+
}
2545

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-
}
46+
const pick = (source, regex, label) => {
47+
const match = source.match(regex)
48+
if (!match) problems.push(`Could not read ${label}.`)
49+
return match ? match[1] : undefined
50+
}
51+
const ciNode = pick(workflow, /DEFAULT_NODE_VERSION:\s*['"]?([0-9][\w.-]*)/, 'DEFAULT_NODE_VERSION from tests-pr.yml')
52+
const ciPnpm = pick(workflow, /PNPM_VERSION:\s*['"]?([0-9][\w.-]*)/, 'PNPM_VERSION from tests-pr.yml')
53+
// dev.yml pins these on the `version:`/`package_manager:` lines under the `node:` step.
54+
const devNode = pick(devYml, /node:\s*\n\s+version:\s*['"]?([0-9][\w.-]*)/, 'the node version from dev.yml')
55+
const devPnpm = pick(devYml, /package_manager:\s*['"]?pnpm@([0-9][\w.-]*)/, 'the pnpm version from dev.yml')
3856

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
57+
if (ciNode && devNode && ciNode !== devNode) {
58+
problems.push(`Node version mismatch: dev.yml ${devNode} vs tests-pr.yml DEFAULT_NODE_VERSION ${ciNode}.`)
59+
}
60+
if (ciPnpm && devPnpm && ciPnpm !== devPnpm) {
61+
problems.push(`pnpm version mismatch: dev.yml ${devPnpm} vs tests-pr.yml PNPM_VERSION ${ciPnpm}.`)
62+
}
63+
64+
return {problems, workflowJobIds, ciNode, ciPnpm}
4565
}
4666

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')
67+
// Run as a CLI when invoked directly (not when imported by a test).
68+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
69+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
70+
const read = (rel) => readFileSync(join(root, rel), 'utf8')
5171

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}.`)
57-
}
72+
const {problems, workflowJobIds, ciNode, ciPnpm} = findProblems({
73+
workflow: read('.github/workflows/tests-pr.yml'),
74+
devYml: read('dev.yml'),
75+
manifestJobIds: MANIFEST_JOB_IDS,
76+
})
5877

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)
78+
if (problems.length > 0) {
79+
console.error('CI gate manifest is out of sync with the workflow:\n')
80+
for (const problem of problems) console.error(`- ${problem}`)
81+
process.exit(1)
82+
}
83+
console.log(
84+
`CI gate manifest in sync: ${workflowJobIds.length} workflow jobs classified; tool versions match (node ${ciNode}, pnpm ${ciPnpm}).`,
85+
)
6386
}
64-
65-
console.log(`CI gate manifest in sync: ${workflowJobIds.length} workflow jobs classified; tool versions match (node ${ciNode}, pnpm ${ciPnpm}).`)

bin/check-ci-gates.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import assert from 'node:assert/strict'
2+
import test from 'node:test'
3+
4+
import {findProblems, parseJobIds} 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+
// Hardening: tolerate a trailing comment after the job id, and ignore deeper-indented
46+
// keys, blank lines, comment lines, and any top-level section after `jobs:`.
47+
test('parseJobIds tolerates comments and ignores non-job lines', () => {
48+
const wf = `env:\n DEFAULT_NODE_VERSION: '26.1.0'\njobs:\n build: # freshness gate\n runs-on: ubuntu-latest\n env:\n NESTED: 1\n\n # a comment line\n test-job:\n steps: []\nconcurrency:\n group: x\n`
49+
assert.deepEqual(parseJobIds(wf), ['build', 'test-job'])
50+
})
51+
52+
test('parseJobIds returns empty when there is no jobs block', () => {
53+
assert.deepEqual(parseJobIds('name: t\non: push\n'), [])
54+
})

0 commit comments

Comments
 (0)