Skip to content

Commit 3f38fd6

Browse files
committed
Add pnpm pre-ci mirroring PR CI gates, with manifest drift guard
1 parent 199434e commit 3f38fd6

6 files changed

Lines changed: 176 additions & 0 deletions

File tree

.github/workflows/tests-pr.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,15 @@ jobs:
316316
run: |
317317
echo '::error::Breaking changes detected. See the sticky comment on the PR for details.'
318318
exit 1
319+
320+
ci-gate-sync:
321+
name: 'CI gate manifest'
322+
runs-on: ubuntu-latest
323+
timeout-minutes: 5
324+
steps:
325+
- uses: actions/checkout@v6
326+
- uses: actions/setup-node@v4
327+
with:
328+
node-version: ${{ env.DEFAULT_NODE_VERSION }}
329+
- name: Check the CI gate manifest matches this workflow
330+
run: node bin/check-ci-gates.js

bin/check-ci-gates.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Drift guard: fails if the local CI-gate manifest (bin/ci-gates.js) falls out of
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).
5+
import {readFileSync} from 'node:fs'
6+
import {fileURLToPath} from 'node:url'
7+
import {dirname, join} from 'node:path'
8+
9+
import {MANIFEST_JOB_IDS} from './ci-gates.js'
10+
11+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
12+
const read = (rel) => readFileSync(join(root, rel), 'utf8')
13+
14+
const problems = []
15+
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])
20+
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))
25+
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+
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+
}
46+
47+
const ciNode = pick(workflow, /DEFAULT_NODE_VERSION:\s*'([^']+)'/, 'DEFAULT_NODE_VERSION in tests-pr.yml')
48+
const devNode = pick(devYml, /node:[\s\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')
51+
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+
}
58+
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+
}
64+
65+
console.log(`CI gate manifest in sync: ${workflowJobIds.length} workflow jobs classified; tool versions match (node ${ciNode}, pnpm ${ciPnpm}).`)

bin/ci-gates.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Single source of truth mapping every job in .github/workflows/tests-pr.yml to
2+
// either a local `pre-ci` command (run-what-CI-runs) or an explicit reason it is
3+
// CI-only. `bin/pre-ci.js` runs the pre-ci gates; `bin/check-ci-gates.js` asserts
4+
// this list stays in sync with the workflow so the two cannot silently drift.
5+
//
6+
// `job` is the workflow job id (the key under `jobs:`), which is stable, unlike
7+
// the rendered display name (matrix jobs interpolate `${{ ... }}`).
8+
9+
export const CI_GATES = [
10+
// --- gates a contributor can reproduce locally before pushing ---
11+
// Ordered as pre-ci should run them: build precedes the oclif codegen check,
12+
// and the graphql check precedes the oclif check (their whole-repo `git status`
13+
// asserts otherwise cross-contaminate in a single working tree).
14+
{job: 'type-check', kind: 'pre-ci', command: 'pnpm type-check'},
15+
{job: 'lint', kind: 'pre-ci', command: 'pnpm lint'},
16+
{job: 'bundle', kind: 'pre-ci', command: 'pnpm build'},
17+
{job: 'knip', kind: 'pre-ci', command: 'pnpm knip'},
18+
{job: 'graphql-schema', kind: 'pre-ci', command: 'pnpm codegen:check:graphql'},
19+
{job: 'oclif-checks', kind: 'pre-ci', command: 'pnpm codegen:check:oclif'},
20+
{job: 'unit-tests', kind: 'pre-ci', command: 'pnpm test'},
21+
22+
// --- CI-only jobs, with the reason they are not part of pre-ci ---
23+
{
24+
job: 'unit-tests-gate',
25+
kind: 'ci-only',
26+
reason: 'Aggregation gate that only collects the unit-tests matrix results; nothing to run locally.',
27+
},
28+
{
29+
job: 'e2e-tests',
30+
kind: 'ci-only',
31+
reason: 'Needs Playwright browsers and real test stores/credentials; too slow and credentialed for a pre-push check.',
32+
},
33+
{
34+
job: 'type-diff',
35+
kind: 'ci-only',
36+
reason: 'Diffs the public type surface against the main branch; needs a base checkout, not a single local working tree.',
37+
},
38+
{
39+
job: 'major-change-check',
40+
kind: 'ci-only',
41+
reason: 'Breaking-change detection against the PR base; advisory and diff-based, not reproducible from one local tree.',
42+
},
43+
{
44+
job: 'ci-gate-sync',
45+
kind: 'ci-only',
46+
reason: 'Meta gate: runs `pnpm check-ci-gates` to keep this manifest in sync with tests-pr.yml. pre-ci runs the same check locally.',
47+
},
48+
]
49+
50+
export const PRE_CI_GATES = CI_GATES.filter((gate) => gate.kind === 'pre-ci')
51+
export const CI_ONLY_GATES = CI_GATES.filter((gate) => gate.kind === 'ci-only')
52+
export const MANIFEST_JOB_IDS = CI_GATES.map((gate) => gate.job)

bin/pre-ci.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Runs the local subset of PR CI gates ("run what CI runs") so contributors can
2+
// catch failures before pushing. The gate list and its parity with the workflow
3+
// are defined in bin/ci-gates.js and enforced by bin/check-ci-gates.js.
4+
//
5+
// pre-ci mirrors CI's full (`--all`) targets so that green locally implies green
6+
// in CI. It is intentionally slower than the affected-only `dev check`.
7+
import {execSync} from 'node:child_process'
8+
9+
import {PRE_CI_GATES, CI_ONLY_GATES} from './ci-gates.js'
10+
11+
const steps = [
12+
{label: 'CI gate manifest in sync', command: 'pnpm check-ci-gates'},
13+
...PRE_CI_GATES.map((gate) => ({label: gate.job, command: gate.command})),
14+
]
15+
16+
const results = []
17+
for (const step of steps) {
18+
process.stdout.write(`\n▶ ${step.label}: ${step.command}\n`)
19+
try {
20+
execSync(step.command, {stdio: 'inherit'})
21+
results.push({...step, ok: true})
22+
} catch {
23+
results.push({...step, ok: false})
24+
}
25+
}
26+
27+
console.log('\n──────── pre-ci summary ────────')
28+
for (const result of results) {
29+
console.log(`${result.ok ? '✓' : '✗'} ${result.label}`)
30+
}
31+
32+
console.log('\nNot run locally (CI-only):')
33+
for (const gate of CI_ONLY_GATES) {
34+
console.log(${gate.job}${gate.reason}`)
35+
}
36+
37+
const failed = results.filter((result) => !result.ok)
38+
if (failed.length > 0) {
39+
console.error(`\npre-ci failed: ${failed.map((result) => result.label).join(', ')}`)
40+
process.exit(1)
41+
}
42+
console.log('\npre-ci passed. Note: codegen checks regenerate files — review `git status` for any uncommitted generated changes.')

dev.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ commands:
6666
type-check:
6767
desc: 'Type-check the project'
6868
run: pnpm run type-check:affected
69+
pre-ci:
70+
desc: 'Run the local subset of PR CI gates (run what CI runs) before pushing'
71+
run: pnpm run pre-ci
6972

7073
check:
7174
type-check: pnpm nx affected --target=type-check

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"clean": "nx run-many --target=clean --all --skip-nx-cache && nx reset",
1313
"codegen": "pnpm graphql-codegen:get-graphql-schemas && pnpm graphql-codegen && pnpm refresh-manifests && pnpm refresh-code-documentation && pnpm build-dev-docs",
1414
"codegen:check:graphql": "pnpm graphql-codegen:get-graphql-schemas && pnpm graphql-codegen && node ./bin/check-codegen-clean.js graphql",
15+
"check-ci-gates": "node bin/check-ci-gates.js",
1516
"codegen:check:oclif": "pnpm refresh-manifests && node ./bin/check-codegen-clean.js oclif:manifests && pnpm refresh-readme && node ./bin/check-codegen-clean.js oclif:readme && pnpm refresh-code-documentation && node ./bin/check-codegen-clean.js oclif:code-docs && pnpm build-dev-docs && node ./bin/check-codegen-clean.js oclif:dev-docs",
1617
"create-app": "nx build create-app && node packages/create-app/bin/dev.js --package-manager pnpm",
1718
"deploy-experimental": "node bin/deploy-experimental.js",
@@ -29,6 +30,7 @@
2930
"refresh-readme": "nx run-many --target=refresh-readme --all --skip-nx-cache",
3031
"release": "./bin/release",
3132
"post-release": "./bin/post-release",
33+
"pre-ci": "node bin/pre-ci.js",
3234
"update-observe": "node bin/update-observe.js",
3335
"shopify:run": "node packages/cli/bin/dev.js",
3436
"shopify": "nx build cli && node packages/cli/bin/dev.js",

0 commit comments

Comments
 (0)