Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/tests-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
65 changes: 65 additions & 0 deletions bin/check-ci-gates.js
Original file line number Diff line number Diff line change
@@ -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}).`)
52 changes: 52 additions & 0 deletions bin/ci-gates.js
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 42 additions & 0 deletions bin/pre-ci.js
Original file line number Diff line number Diff line change
@@ -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.')
3 changes: 3 additions & 0 deletions dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading