Skip to content

Commit 3f375c0

Browse files
Simplify resolveContext to use git merge-base instead of compare API
Per Gonzalo's review: collapses event-payload parsing + per-event-type branching + GitHub compare API call into a single `git merge-base origin/<base> HEAD` + `git diff --name-only`. Drops GITHUB_TOKEN handling and the compare-API fallback path entirely. - workspace/src/major-change-check.js: resolveContext now reads GITHUB_BASE_REF (set on both pull_request and merge_group events) and shells out to git via an injectable runGit. On any git failure we still degrade *open* (changedFiles=null) so we never mask a real breaking change. - .github/workflows/tests-pr.yml: bump fetch-depth 1 -> 0 on the major-change-check job so the merge-base is reachable. - workspace/src/major-change-check.test.js: rewrite the 4 resolveContext tests against a runGit mock; drop fs/event-file fixtures.
1 parent 95c92ff commit 3f375c0

4 files changed

Lines changed: 217 additions & 31 deletions

File tree

.github/workflows/tests-pr.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,9 @@ jobs:
307307
with:
308308
repository: ${{ github.event.pull_request.head.repo.full_name || github.event.repository.full_name }}
309309
ref: ${{ github.event.pull_request.head.ref || github.event.merge_group.head_ref }}
310-
fetch-depth: 1
310+
# Full history so `git merge-base origin/<base> HEAD` (used by
311+
# major-change-check.js) can reach the divergence point.
312+
fetch-depth: 0
311313
- name: Setup deps
312314
uses: ./.github/actions/setup-cli-deps
313315
with:

workspace/src/major-change-check.js

Lines changed: 134 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,20 @@
77
* 2. OCLIF manifest changes: removed commands, removed flags, or removed flag env vars
88
* 3. Zod schema changes: removed or renamed fields in app/extension config schemas
99
*
10-
* Compares the current branch against the main branch baseline.
10+
* Baseline selection:
11+
* - In CI (`GITHUB_BASE_REF` set, by both `pull_request` and `merge_group`)
12+
* the baseline is `git merge-base origin/<base> HEAD`. This avoids false
13+
* positives when the base branch has progressed past the PR's branch
14+
* point (a field added on `main` after the PR opened would otherwise
15+
* show up as "removed" by the PR). Requires `fetch-depth: 0` on the
16+
* workflow checkout.
17+
* - Otherwise (local invocation, no base ref) falls back to `main`'s
18+
* current tip with a full scan (legacy behavior).
19+
*
20+
* The schema scan and OCLIF manifest comparison are scoped to files that
21+
* actually changed in the PR's diff so unrelated drift on `main` cannot
22+
* trigger this check.
23+
*
1124
* Outputs a GitHub Actions summary and sets a `has_breaking_changes` output.
1225
*/
1326

@@ -17,12 +30,65 @@ import {mkdtemp, rm} from 'fs/promises'
1730
import os from 'os'
1831
import {promises as fs} from 'fs'
1932
import {setOutput} from '@actions/core'
33+
import {execa} from 'execa'
2034
import {cloneCLIRepository} from './utils/git.js'
2135
import {logMessage, logSection} from './utils/log.js'
2236
import fg from 'fast-glob'
2337

2438
const currentDirectory = path.join(url.fileURLToPath(new URL('.', import.meta.url)), '../..')
2539

40+
// ---------------------------------------------------------------------------
41+
// Event context: pick the right baseline and the right diff to scan
42+
// ---------------------------------------------------------------------------
43+
44+
/**
45+
* Resolves the baseline + diff context for the check using local git.
46+
*
47+
* `GITHUB_BASE_REF` is set on both `pull_request` and `merge_group` events
48+
* (and points at the target branch in both cases), so a single git
49+
* `merge-base origin/<baseRef> HEAD` gives us the right baseline. The
50+
* surrounding workflow checks out with `fetch-depth: 0` so the merge-base
51+
* is reachable.
52+
*
53+
* Returned shape:
54+
* {
55+
* baselineRef: string, // SHA (or 'main' in fallback)
56+
* changedFiles: Set<string> | null, // null = scan everything (fallback)
57+
* }
58+
*
59+
* On any git failure we degrade *open*: `changedFiles=null` so the scan
60+
* widens rather than silently skipping potential removals. We never want
61+
* to mask a real breaking change because of a transient git error.
62+
*/
63+
export async function resolveContext({
64+
baseRef = process.env.GITHUB_BASE_REF,
65+
runGit = defaultRunGit,
66+
} = {}) {
67+
// No base ref => running locally or in an unsupported event. Fall back
68+
// to legacy behavior so this script remains usable as a manual tool.
69+
if (!baseRef) {
70+
return {baselineRef: 'main', changedFiles: null}
71+
}
72+
73+
try {
74+
const baselineRef = (await runGit(['merge-base', `origin/${baseRef}`, 'HEAD'])).trim()
75+
if (!baselineRef) {
76+
return {baselineRef: 'main', changedFiles: null}
77+
}
78+
const diff = await runGit(['diff', '--name-only', `${baselineRef}...HEAD`])
79+
const changedFiles = new Set(diff.split('\n').filter(Boolean))
80+
return {baselineRef, changedFiles}
81+
} catch (error) {
82+
logMessage(`git merge-base/diff failed: ${error.message} — falling back to full scan against main`)
83+
return {baselineRef: 'main', changedFiles: null}
84+
}
85+
}
86+
87+
async function defaultRunGit(args) {
88+
const {stdout} = await execa('git', args)
89+
return stdout
90+
}
91+
2692
// ---------------------------------------------------------------------------
2793
// 1. Check changesets for major bumps
2894
// ---------------------------------------------------------------------------
@@ -107,9 +173,17 @@ function extractManifestSurface(manifest) {
107173
return surface
108174
}
109175

110-
async function checkManifest(baselineDirectory) {
176+
async function checkManifest(baselineDirectory, {changedFiles} = {}) {
111177
logSection('Checking OCLIF manifest for removed commands/flags')
112178

179+
// The OCLIF manifest is a generated artifact — a removed command always
180+
// shows up as a `oclif.manifest.json` change. If the file isn't in the
181+
// PR diff there is by definition no surface change to detect.
182+
if (changedFiles && !changedFiles.has('packages/cli/oclif.manifest.json')) {
183+
logMessage('oclif.manifest.json unchanged in this PR, skipping')
184+
return {removedCommands: [], removedFlags: [], removedEnvVars: []}
185+
}
186+
113187
const baselineManifest = await parseManifest(baselineDirectory)
114188
const currentManifest = await parseManifest(currentDirectory)
115189

@@ -390,7 +464,7 @@ export function extractSchemaFields(content) {
390464
return fields
391465
}
392466

393-
async function checkSchemas(baselineDirectory) {
467+
async function checkSchemas(baselineDirectory, {changedFiles} = {}) {
394468
logSection('Checking Zod schemas for removed fields')
395469

396470
const schemaGlob = 'packages/app/src/cli/models/**/specifications/**/*.ts'
@@ -402,9 +476,22 @@ async function checkSchemas(baselineDirectory) {
402476
ignore: ignorePatterns,
403477
})
404478

479+
// When we know the PR's actual diff, only inspect schema files the PR
480+
// touched. Without this, drift on `main` (e.g. a field added after the
481+
// PR branched) is reported as a removal. With it, removals come strictly
482+
// from this PR's own changes.
483+
const filesToCheck = changedFiles
484+
? baselineSchemaFiles.filter((file) => changedFiles.has(file))
485+
: baselineSchemaFiles
486+
487+
if (changedFiles && filesToCheck.length === 0) {
488+
logMessage('No schema files changed in this PR, skipping')
489+
return []
490+
}
491+
405492
const removedFields = []
406493

407-
for (const file of baselineSchemaFiles) {
494+
for (const file of filesToCheck) {
408495
const baselinePath = path.join(baselineDirectory, file)
409496
const currentPath = path.join(currentDirectory, file)
410497

@@ -547,29 +634,51 @@ The following Zod schema fields were removed or their files deleted:
547634
// Main
548635
// ---------------------------------------------------------------------------
549636

550-
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'major-change-check-'))
551-
552-
try {
553-
// This script consumes only git-tracked files (oclif.manifest.json + .ts
554-
// sources). It does not need the baseline's node_modules or dist output,
555-
// so we skip pnpm install and pnpm build to save ~5–10 minutes of CI
556-
// per PR. type-diff.js (which diffs `dist/**/*.d.ts`) keeps the default.
557-
const baselineDirectory = await cloneCLIRepository(tmpDir, {install: false, build: false})
637+
// `import.meta.main` is only true when this file is run directly. When the
638+
// test file imports it as a module, we don't want to spawn a baseline clone.
639+
if (process.argv[1] && url.fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
640+
await runMain()
641+
}
558642

559-
const majorChangesets = await checkChangesets()
560-
const manifestChanges = await checkManifest(baselineDirectory)
561-
const schemaChanges = await checkSchemas(baselineDirectory)
643+
async function runMain() {
644+
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'major-change-check-'))
562645

563-
const report = buildReport({majorChangesets, manifestChanges, schemaChanges})
646+
try {
647+
const context = await resolveContext()
648+
if (context.baselineRef !== 'main') {
649+
logMessage(`Resolved baseline to ${context.baselineRef.slice(0, 7)} (merge-base with base branch)`)
650+
}
651+
if (context.changedFiles) {
652+
logMessage(`PR touched ${context.changedFiles.size} file(s); scoping schema/manifest scan to those`)
653+
}
564654

565-
if (report) {
566-
logSection('\n⚠️ Breaking changes detected!')
567-
setOutput('has_breaking_changes', 'true')
568-
setOutput('report', report)
569-
} else {
570-
logSection('\n✅ No breaking changes detected')
571-
setOutput('has_breaking_changes', 'false')
655+
// This script consumes only git-tracked files (oclif.manifest.json + .ts
656+
// sources). It does not need the baseline's node_modules or dist output,
657+
// so we skip pnpm install and pnpm build to save ~5–10 minutes of CI
658+
// per PR. type-diff.js (which diffs `dist/**/*.d.ts`) keeps the default.
659+
const baselineDirectory = await cloneCLIRepository(tmpDir, {
660+
install: false,
661+
build: false,
662+
ref: context.baselineRef,
663+
})
664+
665+
const majorChangesets = await checkChangesets()
666+
const manifestChanges = await checkManifest(baselineDirectory, {changedFiles: context.changedFiles})
667+
const schemaChanges = await checkSchemas(baselineDirectory, {changedFiles: context.changedFiles})
668+
669+
const report = buildReport({majorChangesets, manifestChanges, schemaChanges})
670+
671+
if (report) {
672+
logSection('\n⚠️ Breaking changes detected!')
673+
setOutput('has_breaking_changes', 'true')
674+
setOutput('report', report)
675+
} else {
676+
logSection('\n✅ No breaking changes detected')
677+
setOutput('has_breaking_changes', 'false')
678+
}
679+
} finally {
680+
await rm(tmpDir, {recursive: true, force: true, maxRetries: 2})
572681
}
573-
} finally {
574-
await rm(tmpDir, {recursive: true, force: true, maxRetries: 2})
575682
}
683+
684+

workspace/src/major-change-check.test.js

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {test} from 'node:test'
1414
import assert from 'node:assert/strict'
1515

16-
import {extractSchemaFields, stripStringsAndComments} from './major-change-check.js'
16+
import {extractSchemaFields, resolveContext, stripStringsAndComments} from './major-change-check.js'
1717

1818
test('extracts top-level keys from a flat .object({...})', () => {
1919
const src = `
@@ -135,3 +135,64 @@ test('stripStringsAndComments preserves length and newlines', () => {
135135
assert.equal(stripped.includes('hello'), false)
136136
assert.equal(stripped.includes('trailing'), false)
137137
})
138+
139+
// ---------------------------------------------------------------------------
140+
// resolveContext()
141+
// ---------------------------------------------------------------------------
142+
143+
test('resolveContext: derives baseline from `git merge-base origin/<base> HEAD`', async () => {
144+
const calls = []
145+
const runGit = async (args) => {
146+
calls.push(args)
147+
if (args[0] === 'merge-base') {
148+
assert.deepEqual(args, ['merge-base', 'origin/main', 'HEAD'])
149+
return 'mergebase123\n'
150+
}
151+
if (args[0] === 'diff') {
152+
assert.deepEqual(args, ['diff', '--name-only', 'mergebase123...HEAD'])
153+
return 'packages/app/foo.ts\npackages/cli/oclif.manifest.json\n'
154+
}
155+
throw new Error(`unexpected git call: ${args.join(' ')}`)
156+
}
157+
const ctx = await resolveContext({baseRef: 'main', runGit})
158+
assert.equal(ctx.baselineRef, 'mergebase123', 'uses merge-base SHA, trimmed')
159+
assert.deepEqual(
160+
[...ctx.changedFiles].sort(),
161+
['packages/app/foo.ts', 'packages/cli/oclif.manifest.json'],
162+
)
163+
assert.equal(calls.length, 2, 'one merge-base + one diff call')
164+
})
165+
166+
test('resolveContext: respects non-default base branches (e.g. release branches)', async () => {
167+
const runGit = async (args) => {
168+
if (args[0] === 'merge-base') {
169+
assert.equal(args[1], 'origin/release/3.0', 'forwarded GITHUB_BASE_REF unchanged')
170+
return 'releasebase\n'
171+
}
172+
return ''
173+
}
174+
const ctx = await resolveContext({baseRef: 'release/3.0', runGit})
175+
assert.equal(ctx.baselineRef, 'releasebase')
176+
})
177+
178+
test('resolveContext: git failure degrades to scanning everything against main', async () => {
179+
const runGit = async () => {
180+
throw new Error('fatal: Not a valid object name origin/main')
181+
}
182+
const ctx = await resolveContext({baseRef: 'main', runGit})
183+
// We'd rather over-flag than silently miss a real removal.
184+
assert.equal(ctx.baselineRef, 'main')
185+
assert.equal(ctx.changedFiles, null, 'git failure must NOT collapse to an empty diff set')
186+
})
187+
188+
test('resolveContext: no GITHUB_BASE_REF falls back to scanning main (local invocation)', async () => {
189+
let called = false
190+
const runGit = async () => {
191+
called = true
192+
return ''
193+
}
194+
const ctx = await resolveContext({baseRef: undefined, runGit})
195+
assert.equal(ctx.baselineRef, 'main')
196+
assert.equal(ctx.changedFiles, null)
197+
assert.equal(called, false, 'must not shell out to git when no base ref is known')
198+
})

workspace/src/utils/git.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import git from 'simple-git'
44
import {logMessage, logSection} from './log.js'
55

66
/**
7-
* Clone the Shopify/cli `main` branch into `tmpDir/cli`.
7+
* Clone the Shopify/cli repository into `tmpDir/cli` and check out `ref`.
8+
*
9+
* `ref` defaults to `main` to preserve historical behavior. Callers that
10+
* want to diff against the merge-base of a PR (rather than against the
11+
* current tip of `main`) should pass the merge-base SHA explicitly so the
12+
* baseline reflects the actual fork point and not whatever has landed on
13+
* `main` since the PR was opened.
814
*
915
* `install` and `build` default to `true` to preserve behavior for
1016
* `type-diff.js`, which diffs `dist/**\/*.d.ts` and therefore needs the
@@ -15,14 +21,21 @@ import {logMessage, logSection} from './log.js'
1521
* PR for those callers.
1622
*
1723
* @param {string} tmpDir
18-
* @param {{install?: boolean, build?: boolean}} [options]
24+
* @param {{install?: boolean, build?: boolean, ref?: string}} [options]
1925
* @returns {Promise<string>} path to the cloned repository
2026
*/
21-
export async function cloneCLIRepository(tmpDir, {install = true, build = true} = {}) {
22-
logSection('Setting up baseline: main branch')
27+
export async function cloneCLIRepository(tmpDir, {install = true, build = true, ref = 'main'} = {}) {
28+
logSection(`Setting up baseline: ${ref}`)
2329
const directory = path.join(tmpDir, 'cli')
2430
logMessage('Cloning repository')
31+
// Full clone (no `--depth`) so any historical SHA passed as `ref` is
32+
// reachable. Shopify/cli's history is small enough that this is fast
33+
// and the clone is throwaway.
2534
await git().clone('https://github.com/Shopify/cli.git', directory)
35+
if (ref !== 'main') {
36+
logMessage(`Checking out ${ref}`)
37+
await git(directory).checkout(ref)
38+
}
2639
if (install) {
2740
logMessage('Installing dependencies')
2841
await execa('pnpm', ['install'], {cwd: directory})
@@ -33,3 +46,4 @@ export async function cloneCLIRepository(tmpDir, {install = true, build = true}
3346
}
3447
return directory
3548
}
49+

0 commit comments

Comments
 (0)