diff --git a/.github/workflows/tests-pr.yml b/.github/workflows/tests-pr.yml index 5e35457511..3609208ec5 100644 --- a/.github/workflows/tests-pr.yml +++ b/.github/workflows/tests-pr.yml @@ -316,3 +316,15 @@ jobs: run: | echo '::error::Breaking changes detected. See the sticky comment on the PR for details.' exit 1 + + ci-gate-sync: + name: 'CI gate manifest' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.DEFAULT_NODE_VERSION }} + - name: Check the CI gate manifest matches this workflow + run: node bin/check-ci-gates.js diff --git a/bin/check-ci-gates.js b/bin/check-ci-gates.js new file mode 100644 index 0000000000..569bf3b2a2 --- /dev/null +++ b/bin/check-ci-gates.js @@ -0,0 +1,65 @@ +// Drift guard: fails if the local CI-gate manifest (bin/ci-gates.js) falls out of +// sync with .github/workflows/tests-pr.yml, or if the pinned tool versions in +// dev.yml and tests-pr.yml disagree. Runs in CI (ci-gate-sync job) and locally +// (pnpm check-ci-gates, also invoked by pre-ci). +import {readFileSync} from 'node:fs' +import {fileURLToPath} from 'node:url' +import {dirname, join} from 'node:path' + +import {MANIFEST_JOB_IDS} from './ci-gates.js' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const read = (rel) => readFileSync(join(root, rel), 'utf8') + +const problems = [] + +// 1. Workflow job ids must exactly match the manifest job ids. +const workflow = read('.github/workflows/tests-pr.yml') +const jobsSection = workflow.slice(workflow.search(/^jobs:/m)) +const workflowJobIds = [...jobsSection.matchAll(/^ {2}([A-Za-z0-9_-]+):\s*$/gm)].map((match) => match[1]) + +const manifestSet = new Set(MANIFEST_JOB_IDS) +const workflowSet = new Set(workflowJobIds) +const missingFromManifest = workflowJobIds.filter((id) => !manifestSet.has(id)) +const staleInManifest = MANIFEST_JOB_IDS.filter((id) => !workflowSet.has(id)) + +if (missingFromManifest.length > 0) { + problems.push( + `Workflow jobs not classified in bin/ci-gates.js: ${missingFromManifest.join(', ')}.\n` + + ` Add each to CI_GATES as a 'pre-ci' gate (with a local command) or 'ci-only' (with a reason).`, + ) +} +if (staleInManifest.length > 0) { + problems.push( + `bin/ci-gates.js lists jobs absent from tests-pr.yml: ${staleInManifest.join(', ')}.\n` + + ` Remove them or fix the job id.`, + ) +} + +// 2. Pinned tool versions must agree between dev.yml and tests-pr.yml. +const devYml = read('dev.yml') +const pick = (source, regex, label) => { + const match = source.match(regex) + if (!match) problems.push(`Could not parse ${label}.`) + return match ? match[1] : null +} + +const ciNode = pick(workflow, /DEFAULT_NODE_VERSION:\s*'([^']+)'/, 'DEFAULT_NODE_VERSION in tests-pr.yml') +const devNode = pick(devYml, /node:[\s\S]*?version:\s*([0-9][\w.-]*)/, 'node version in dev.yml') +const ciPnpm = pick(workflow, /PNPM_VERSION:\s*'([^']+)'/, 'PNPM_VERSION in tests-pr.yml') +const devPnpm = pick(devYml, /package_manager:\s*pnpm@([0-9][\w.-]*)/, 'pnpm version in dev.yml') + +if (ciNode && devNode && ciNode !== devNode) { + problems.push(`Node version mismatch: dev.yml ${devNode} vs tests-pr.yml DEFAULT_NODE_VERSION ${ciNode}.`) +} +if (ciPnpm && devPnpm && ciPnpm !== devPnpm) { + problems.push(`pnpm version mismatch: dev.yml ${devPnpm} vs tests-pr.yml PNPM_VERSION ${ciPnpm}.`) +} + +if (problems.length > 0) { + console.error('CI gate manifest is out of sync with the workflow:\n') + for (const problem of problems) console.error(`- ${problem}`) + process.exit(1) +} + +console.log(`CI gate manifest in sync: ${workflowJobIds.length} workflow jobs classified; tool versions match (node ${ciNode}, pnpm ${ciPnpm}).`) diff --git a/bin/ci-gates.js b/bin/ci-gates.js new file mode 100644 index 0000000000..715f50f29a --- /dev/null +++ b/bin/ci-gates.js @@ -0,0 +1,52 @@ +// Single source of truth mapping every job in .github/workflows/tests-pr.yml to +// either a local `pre-ci` command (run-what-CI-runs) or an explicit reason it is +// CI-only. `bin/pre-ci.js` runs the pre-ci gates; `bin/check-ci-gates.js` asserts +// this list stays in sync with the workflow so the two cannot silently drift. +// +// `job` is the workflow job id (the key under `jobs:`), which is stable, unlike +// the rendered display name (matrix jobs interpolate `${{ ... }}`). + +export const CI_GATES = [ + // --- gates a contributor can reproduce locally before pushing --- + // Ordered as pre-ci should run them: build precedes the oclif codegen check, + // and the graphql check precedes the oclif check (their whole-repo `git status` + // asserts otherwise cross-contaminate in a single working tree). + {job: 'type-check', kind: 'pre-ci', command: 'pnpm type-check'}, + {job: 'lint', kind: 'pre-ci', command: 'pnpm lint'}, + {job: 'bundle', kind: 'pre-ci', command: 'pnpm build'}, + {job: 'knip', kind: 'pre-ci', command: 'pnpm knip'}, + {job: 'graphql-schema', kind: 'pre-ci', command: 'pnpm codegen:check:graphql'}, + {job: 'oclif-checks', kind: 'pre-ci', command: 'pnpm codegen:check:oclif'}, + {job: 'unit-tests', kind: 'pre-ci', command: 'pnpm test'}, + + // --- CI-only jobs, with the reason they are not part of pre-ci --- + { + job: 'unit-tests-gate', + kind: 'ci-only', + reason: 'Aggregation gate that only collects the unit-tests matrix results; nothing to run locally.', + }, + { + job: 'e2e-tests', + kind: 'ci-only', + reason: 'Needs Playwright browsers and real test stores/credentials; too slow and credentialed for a pre-push check.', + }, + { + job: 'type-diff', + kind: 'ci-only', + reason: 'Diffs the public type surface against the main branch; needs a base checkout, not a single local working tree.', + }, + { + job: 'major-change-check', + kind: 'ci-only', + reason: 'Breaking-change detection against the PR base; advisory and diff-based, not reproducible from one local tree.', + }, + { + job: 'ci-gate-sync', + kind: 'ci-only', + 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.', + }, +] + +export const PRE_CI_GATES = CI_GATES.filter((gate) => gate.kind === 'pre-ci') +export const CI_ONLY_GATES = CI_GATES.filter((gate) => gate.kind === 'ci-only') +export const MANIFEST_JOB_IDS = CI_GATES.map((gate) => gate.job) diff --git a/bin/pre-ci.js b/bin/pre-ci.js new file mode 100644 index 0000000000..e148a35d30 --- /dev/null +++ b/bin/pre-ci.js @@ -0,0 +1,42 @@ +// Runs the local subset of PR CI gates ("run what CI runs") so contributors can +// catch failures before pushing. The gate list and its parity with the workflow +// are defined in bin/ci-gates.js and enforced by bin/check-ci-gates.js. +// +// pre-ci mirrors CI's full (`--all`) targets so that green locally implies green +// in CI. It is intentionally slower than the affected-only `dev check`. +import {execSync} from 'node:child_process' + +import {PRE_CI_GATES, CI_ONLY_GATES} from './ci-gates.js' + +const steps = [ + {label: 'CI gate manifest in sync', command: 'pnpm check-ci-gates'}, + ...PRE_CI_GATES.map((gate) => ({label: gate.job, command: gate.command})), +] + +const results = [] +for (const step of steps) { + process.stdout.write(`\n▶ ${step.label}: ${step.command}\n`) + try { + execSync(step.command, {stdio: 'inherit'}) + results.push({...step, ok: true}) + } catch { + results.push({...step, ok: false}) + } +} + +console.log('\n──────── pre-ci summary ────────') +for (const result of results) { + console.log(`${result.ok ? '✓' : '✗'} ${result.label}`) +} + +console.log('\nNot run locally (CI-only):') +for (const gate of CI_ONLY_GATES) { + console.log(`· ${gate.job} — ${gate.reason}`) +} + +const failed = results.filter((result) => !result.ok) +if (failed.length > 0) { + console.error(`\npre-ci failed: ${failed.map((result) => result.label).join(', ')}`) + process.exit(1) +} +console.log('\npre-ci passed. Note: codegen checks regenerate files — review `git status` for any uncommitted generated changes.') diff --git a/dev.yml b/dev.yml index 6c6bffb6bf..8f64660a55 100644 --- a/dev.yml +++ b/dev.yml @@ -66,6 +66,9 @@ commands: type-check: desc: 'Type-check the project' run: pnpm run type-check:affected + pre-ci: + desc: 'Run the local subset of PR CI gates (run what CI runs) before pushing' + run: pnpm run pre-ci check: type-check: pnpm nx affected --target=type-check diff --git a/package.json b/package.json index a28dce780f..83ffcffd32 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "clean": "nx run-many --target=clean --all --skip-nx-cache && nx reset", "codegen": "pnpm graphql-codegen:get-graphql-schemas && pnpm graphql-codegen && pnpm refresh-manifests && pnpm refresh-code-documentation && pnpm build-dev-docs", "codegen:check:graphql": "pnpm graphql-codegen:get-graphql-schemas && pnpm graphql-codegen && node ./bin/check-codegen-clean.js graphql", + "check-ci-gates": "node bin/check-ci-gates.js", "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", "create-app": "nx build create-app && node packages/create-app/bin/dev.js --package-manager pnpm", "deploy-experimental": "node bin/deploy-experimental.js", @@ -29,6 +30,7 @@ "refresh-readme": "nx run-many --target=refresh-readme --all --skip-nx-cache", "release": "./bin/release", "post-release": "./bin/post-release", + "pre-ci": "node bin/pre-ci.js", "update-observe": "node bin/update-observe.js", "shopify:run": "node packages/cli/bin/dev.js", "shopify": "nx build cli && node packages/cli/bin/dev.js",