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
22 changes: 16 additions & 6 deletions bin/ci-gates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
{
Expand Down
106 changes: 89 additions & 17 deletions bin/pre-ci.js
Original file line number Diff line number Diff line change
@@ -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})
Expand All @@ -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.')
3 changes: 3 additions & 0 deletions dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading