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'
1730import os from 'os'
1831import { promises as fs } from 'fs'
1932import { setOutput } from '@actions/core'
33+ import { execa } from 'execa'
2034import { cloneCLIRepository } from './utils/git.js'
2135import { logMessage , logSection } from './utils/log.js'
2236import fg from 'fast-glob'
2337
2438const 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+
0 commit comments