@@ -10,7 +10,7 @@ import { $, echo, fs } from 'zx';
1010 * Without --check: runs `changeset add` interactively with the correct upstream remote
1111 * auto-detected from package.json's repository URL, temporarily patched into config.json.
1212 *
13- * With --check (CI mode): validates that all changed packages have changesets and that
13+ * With --check (CI mode): validates that all changed public packages have changesets and that
1414 * no major version bumps are introduced.
1515 */
1616
@@ -44,19 +44,62 @@ async function getBaseBranch(): Promise<string> {
4444 return `${ remote } /main` ;
4545}
4646
47- /** Run `changeset add` interactively, temporarily patching config.json with the correct baseBranch. */
48- async function runAdd ( baseBranch : string ) : Promise < void > {
49- const configPath = '.changeset/config.json' ;
50- const config = fs . readJsonSync ( configPath ) ;
51- const originalBaseBranch : string = config . baseBranch ;
52- config . baseBranch = baseBranch ;
53- fs . writeJsonSync ( configPath , config , { spaces : 2 } ) ;
47+ /** Run `changeset status` and return the output and exit code. */
48+ async function getChangesetStatus ( baseBranch : string ) : Promise < { data: ChangesetStatusOutput ; exitCode: number } > {
49+ const STATUS_FILE = 'changeset-status.json' ;
5450
55- try {
56- await $ ( { stdio : 'inherit' } ) `yarn changeset` ;
57- } finally {
58- config . baseBranch = originalBaseBranch ;
59- fs . writeJsonSync ( configPath , config , { spaces : 2 } ) ;
51+ fs . writeJsonSync ( STATUS_FILE , { releases : [ ] , changesets : [ ] } ) ;
52+ const result = await $ `yarn changeset status --since=${ baseBranch } --output ${ STATUS_FILE } ` . nothrow ( ) ;
53+ const data : ChangesetStatusOutput = fs . readJsonSync ( STATUS_FILE ) ;
54+ fs . removeSync ( STATUS_FILE ) ;
55+
56+ return { data, exitCode : result . exitCode ?? 1 } ;
57+ }
58+
59+ /** Returns names of all private workspace packages. */
60+ async function getPrivatePackages ( ) : Promise < Set < string >> {
61+ const output = ( await $ `yarn workspaces list --json` . quiet ( ) ) . stdout ;
62+ const workspaces = output
63+ . trim ( )
64+ . split ( '\n' )
65+ . filter ( Boolean )
66+ . map ( ( line ) => JSON . parse ( line ) )
67+ . filter ( ( e ) : e is { location : string ; name : string } = > typeof e . location === 'string' && typeof e . name === 'string' ) ;
68+
69+ const names = workspaces
70+ . filter ( ( { location } ) => {
71+ try {
72+ return fs . readJsonSync ( `${ location } /package.json` ) . private === true ;
73+ } catch {
74+ return false ;
75+ }
76+ } )
77+ . map ( ( { name } ) => name ) ;
78+
79+ return new Set ( names ) ;
80+ }
81+
82+ /** Fail if any .changeset/*.md file bumps a private package. */
83+ function checkNoPrivatePackageBumps ( privatePackages : Set < string > ) : void {
84+ const files = fs . readdirSync ( '.changeset' ) . filter ( ( f : string ) => f . endsWith ( '.md' ) ) ;
85+ for ( const file of files ) {
86+ const content = fs . readFileSync ( `.changeset/${ file } ` , 'utf-8' ) ;
87+ const frontmatter = content . match ( / ^ - - - \n ( [ \s \S ] * ?) \n - - - / ) ?. [ 1 ] ?? '' ;
88+ const bumped = frontmatter
89+ . split ( '\n' )
90+ . map ( ( line : string ) =>
91+ line
92+ . split ( ':' ) [ 0 ]
93+ . trim ( )
94+ . replace ( / ^ [ ' " ] | [ ' " ] $ / g, '' ) ,
95+ )
96+ . filter ( Boolean ) ;
97+ const privateBumps = bumped . filter ( ( name : string ) => privatePackages . has ( name ) ) ;
98+ if ( privateBumps . length > 0 ) {
99+ log . error ( `❌ Changeset ${ file } bumps private packages: ${ privateBumps . join ( ', ' ) } ` ) ;
100+ log . warn ( 'Remove the private package entries from the changeset.\n' ) ;
101+ process . exit ( 1 ) ;
102+ }
60103 }
61104}
62105
@@ -76,39 +119,40 @@ function checkMajorBumps(releases: ChangesetStatusOutput['releases']): void {
76119 process . exit ( 1 ) ;
77120}
78121
79- /** Validate that all changed packages have changesets and no major bumps are introduced. */
80- async function runCheck ( baseBranch : string ) : Promise < void > {
81- const STATUS_FILE = 'changeset-status.json' ;
122+ /** Run `changeset add` interactively, temporarily patching config.json with the correct baseBranch. */
123+ async function runAdd ( baseBranch : string ) : Promise < void > {
124+ const configPath = '.changeset/config.json' ;
125+ const config = fs . readJsonSync ( configPath ) ;
126+ const originalBaseBranch : string = config . baseBranch ;
127+ config . baseBranch = baseBranch ;
128+ fs . writeJsonSync ( configPath , config , { spaces : 2 } ) ;
82129
83- log . info ( `\n${ '=' . repeat ( 60 ) } ` ) ;
84- log . info ( 'Changesets Validation' ) ;
85- log . info ( `${ '=' . repeat ( 60 ) } \n` ) ;
86- log . info ( `Comparing against ${ baseBranch } \n` ) ;
130+ try {
131+ await $ ( { stdio : 'inherit' } ) `yarn changeset `;
132+ } finally {
133+ config.baseBranch = originalBaseBranch;
134+ fs.writeJsonSync(configPath, config, { spaces: 2 });
135+ }
136+ }
87137
88- // Pre-write empty state so changeset status always has a file to overwrite
89- fs . writeJsonSync ( STATUS_FILE , { releases : [ ] , changesets : [ ] } ) ;
138+ /** Validate that all changed public packages have changesets and no major bumps are introduced. */
139+ async function runCheck(baseBranch: string): Promise<void> {
140+ log.info(` Validating changesets against ${baseBranch } ...\n`);
90141
91- const statusResult = await $ `yarn changeset status --since= ${ baseBranch } --output ${ STATUS_FILE } ` . nothrow ( ) ;
142+ checkNoPrivatePackageBumps( await getPrivatePackages() );
92143
93- const data : ChangesetStatusOutput = fs . readJsonSync ( STATUS_FILE ) ;
94- fs . removeSync ( STATUS_FILE ) ;
144+ const { data, exitCode } = await getChangesetStatus(baseBranch);
95145
96- // Fail: packages changed but no changeset written
97- if ( statusResult . exitCode !== 0 ) {
146+ if (exitCode !== 0) {
98147 log.error('❌ Some packages have been changed but no changesets were found.');
99148 log.warn('Run ` yarn change ` to create a changeset, or ` yarn changeset -- empty ` if no release is needed.\n');
100149 process.exit(1);
101150 }
102151
103152 checkMajorBumps(data.releases);
104153
105- // Pass
106- if ( data . releases . length === 0 ) {
107- log . info ( 'ℹ️ No public packages changed — no changeset required' ) ;
108- } else {
109- log . success ( `✅ Changesets found (${ data . releases . map ( ( r ) => r . name ) . join ( ', ' ) } )` ) ;
110- }
111- log . success ( '\nAll validations passed! ✅\n' ) ;
154+ const packages = data.releases.map((r) => r.name).join(', ');
155+ log.success(packages ? ` ✅ All validations passed ( $ { packages} ) ` : '✅ All validations passed' ) ;
112156}
113157
114158const { values : args } = parseArgs ( { options : { check : { type : 'boolean' , default : false } } } ) ;
0 commit comments