diff --git a/bin/ci-gates.js b/bin/ci-gates.js index 715f50f29a..ee5595f861 100644 --- a/bin/ci-gates.js +++ b/bin/ci-gates.js @@ -5,19 +5,29 @@ // // `job` is the workflow job id (the key under `jobs:`), which is stable, unlike // the rendered display name (matrix jobs interpolate `${{ ... }}`). +// +// pre-ci gates may declare: +// command — full-parity command (run by `pnpm pre-ci`) +// affected — faster command for `pnpm pre-ci:affected` (defaults to `command`) +// affectedWhen: 'codegen' — in affected mode, run only when the diff plausibly +// changes generated output; otherwise skip with a reminder. +// +// Affected commands base on `origin/main` (not the local `main` that nx's +// defaultBase would use), matching the codegen/vitest change detection so all of +// pre-ci:affected reasons about the same diff. 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: 'type-check', kind: 'pre-ci', command: 'pnpm type-check', affected: 'pnpm exec nx affected --target=type-check --base=origin/main'}, + {job: 'lint', kind: 'pre-ci', command: 'pnpm lint', affected: 'pnpm exec nx affected --target=lint --base=origin/main'}, + {job: 'bundle', kind: 'pre-ci', command: 'pnpm build', affected: 'pnpm exec nx affected --target=build --base=origin/main'}, {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'}, + {job: 'graphql-schema', kind: 'pre-ci', command: 'pnpm codegen:check:graphql', affectedWhen: 'codegen'}, + {job: 'oclif-checks', kind: 'pre-ci', command: 'pnpm codegen:check:oclif', affectedWhen: 'codegen'}, + {job: 'unit-tests', kind: 'pre-ci', command: 'pnpm test', affected: 'pnpm vitest run --changed origin/main'}, // --- CI-only jobs, with the reason they are not part of pre-ci --- { diff --git a/bin/pre-ci.js b/bin/pre-ci.js index e148a35d30..7b56f1e325 100644 --- a/bin/pre-ci.js +++ b/bin/pre-ci.js @@ -1,21 +1,89 @@ -// 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. +// Runs the local 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`. +// pnpm pre-ci full parity with CI's `--all` targets (slower) +// pnpm pre-ci:affected only what your diff touches (faster inner loop) +// +// Affected mode runs the nx/vitest affected variants and skips the codegen +// freshness checks unless the diff plausibly changes generated output. 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 affected = process.argv.includes('--affected') + +// git may wrap paths with special chars in quotes; strip them. +function unquote(path) { + return path.replace(/^"(.*)"$/, '$1') +} + +// Changed files vs the merge-base with origin/main, plus the working tree. +// Returns null if detection fails, so callers can fail safe (assume relevant). +function changedFiles() { + try { + const base = execSync('git merge-base HEAD origin/main', {encoding: 'utf8'}).trim() + const committed = execSync(`git diff --name-only ${base}..HEAD`, {encoding: 'utf8'}) + const working = execSync('git status --porcelain', {encoding: 'utf8'}) + const files = new Set() + for (const line of committed.split('\n')) { + const path = unquote(line.trim()) + if (path) files.add(path) + } + for (const line of working.split('\n')) { + if (!line.trim()) continue + let entry = line.slice(3).trim() + if (entry.includes(' -> ')) entry = entry.split(' -> ').pop() // rename: keep the new path + files.add(unquote(entry)) + } + return [...files] + } catch { + return null + } +} + +function touchesGeneratedOutput(files) { + if (files === null) return true + return files.some( + (file) => file.includes('/commands/') || file.endsWith('.graphql') || /graphql/i.test(file) || file.startsWith('docs-shopify.dev/'), + ) +} + +// Affected mode bases on origin/main. Warn if the local main has drifted from it, +// since that usually means origin/main is stale and the affected set may be off. +function localMainDriftWarning() { + try { + const local = execSync('git rev-parse main', {encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore']}).trim() + const remote = execSync('git rev-parse origin/main', {encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore']}).trim() + if (local !== remote) { + return 'Local `main` differs from `origin/main` (affected mode bases on `origin/main`). Run `git fetch origin main` if the affected set looks off.' + } + } catch { + // No local `main` ref (e.g. a fresh worktree) — nothing to compare. + } + return null +} + +const diff = affected ? changedFiles() : null +const codegenRelevant = affected ? touchesGeneratedOutput(diff) : true + +const driftWarning = affected ? localMainDriftWarning() : null +if (driftWarning) process.stdout.write(`\n⚠ ${driftWarning}\n`) + +const steps = [{label: 'CI gate manifest in sync', command: 'pnpm check-ci-gates'}] +const skipped = [] +for (const gate of PRE_CI_GATES) { + if (affected && gate.affectedWhen === 'codegen' && !codegenRelevant) { + skipped.push({job: gate.job, reason: 'affected mode: diff does not touch commands, flags, or GraphQL'}) + continue + } + const command = affected ? gate.affected ?? gate.command : gate.command + steps.push({label: affected ? `${gate.job} (affected)` : gate.job, command}) +} const results = [] for (const step of steps) { - process.stdout.write(`\n▶ ${step.label}: ${step.command}\n`) + process.stdout.write(`\n\u25b6 ${step.label}: ${step.command}\n`) try { execSync(step.command, {stdio: 'inherit'}) results.push({...step, ok: true}) @@ -24,19 +92,23 @@ for (const step of steps) { } } -console.log('\n──────── pre-ci summary ────────') -for (const result of results) { - console.log(`${result.ok ? '✓' : '✗'} ${result.label}`) +console.log(`\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 pre-ci${affected ? ' (affected)' : ''} summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`) +for (const result of results) console.log(`${result.ok ? '\u2713' : '\u2717'} ${result.label}`) +for (const gate of skipped) console.log(`\u00b7 ${gate.job} \u2014 skipped (${gate.reason})`) + +if (affected) { + console.log('\nAffected mode is a fast pre-push check, not full CI parity. Run `pnpm pre-ci` before a high-risk push.') + if (skipped.length > 0) { + console.log('If you changed commands, flags, or GraphQL queries, run `pnpm codegen` and commit the result.') + } } console.log('\nNot run locally (CI-only):') -for (const gate of CI_ONLY_GATES) { - console.log(`· ${gate.job} — ${gate.reason}`) -} +for (const gate of CI_ONLY_GATES) console.log(`\u00b7 ${gate.job} \u2014 ${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.') +console.log('\npre-ci passed. Codegen checks regenerate files — review `git status` for uncommitted generated changes.') diff --git a/dev.yml b/dev.yml index 8f64660a55..3279632f30 100644 --- a/dev.yml +++ b/dev.yml @@ -69,6 +69,9 @@ commands: pre-ci: desc: 'Run the local subset of PR CI gates (run what CI runs) before pushing' run: pnpm run pre-ci + pre-ci:affected: + desc: 'Faster pre-ci limited to what your diff touches' + run: pnpm run pre-ci:affected check: type-check: pnpm nx affected --target=type-check diff --git a/package.json b/package.json index b53670b7e4..d953af68f5 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "release": "./bin/release", "post-release": "./bin/post-release", "pre-ci": "node bin/pre-ci.js", + "pre-ci:affected": "node bin/pre-ci.js --affected", "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",