11import semver from 'semver' ;
2+ import { parse } from 'yaml' ;
23import { writeFile , readFile } from 'fs/promises' ;
34import { resolve } from 'path' ;
45import child_process from 'child_process' ;
@@ -7,14 +8,19 @@ import assert from 'assert';
78
89const exec = promisify ( child_process . exec ) ;
910
11+ /**
12+ * @param {string | semver.SemVer } currentVersion
13+ */
1014function generateExperimentalVersion ( currentVersion ) {
1115 const parsed = semver . parse ( currentVersion ) ;
1216 if ( ! parsed ) throw new Error ( `Invalid version: ${ currentVersion } ` ) ;
1317
1418 // Check if it's already an experimental version
1519 if ( parsed . prerelease . length > 0 && parsed . prerelease [ 0 ] === 'exp' ) {
20+ const minor = parsed . prerelease [ 1 ] || 0 ;
21+ const minorInt = typeof minor === 'string' ? parseInt ( minor ) : minor ;
1622 // Increment the experimental minor version
17- const expMinor = ( parsed . prerelease [ 1 ] || 0 ) + 1 ;
23+ const expMinor = minorInt + 1 ;
1824 return `${ parsed . major } .${ parsed . minor } .${ parsed . patch } -exp.${ expMinor } ` ;
1925 }
2026
@@ -23,7 +29,10 @@ function generateExperimentalVersion(currentVersion) {
2329}
2430
2531const rootDir = process . cwd ( ) ;
26- const releaseType = process . env . RELEASE_TYPE ;
32+
33+ const releaseType = /** @type { import('semver').ReleaseType | "experimental" } */ (
34+ process . env . RELEASE_TYPE
35+ ) ;
2736assert . match ( releaseType , / ^ ( p a t c h | m i n o r | m a j o r | e x p e r i m e n t a l | p r e m a j o r ) $ / , 'Invalid RELEASE_TYPE' ) ;
2837
2938// TODO: if releaseType is `auto` determine release type based on the changelog
@@ -39,8 +48,12 @@ const packages = JSON.parse(
3948
4049const packageMap = { } ;
4150for ( let { name, path, version, private : isPrivate } of packages ) {
42- if ( isPrivate && path !== rootDir ) continue ;
43- if ( path === rootDir ) name = 'monorepo-root' ;
51+ if ( isPrivate && path !== rootDir ) {
52+ continue ;
53+ }
54+ if ( path === rootDir ) {
55+ name = 'monorepo-root' ;
56+ }
4457
4558 const isDirty = await exec ( `git diff --quiet HEAD ${ lastTag } -- ${ path } ` )
4659 . then ( ( ) => false )
@@ -57,19 +70,102 @@ assert.ok(
5770// Propagate isDirty transitively: if a package's dependency will be bumped,
5871// that package also needs a bump (e.g. design-system → editor-ui → cli).
5972
73+ // Detect root-level changes that affect resolved dep versions without touching individual
74+ // package.json files: pnpm.overrides (applies to all specifiers)
75+ // and pnpm-workspace.yaml catalog entries (applies only to deps using a "catalog:…" specifier).
76+
77+ const rootPkgJson = JSON . parse ( await readFile ( resolve ( rootDir , 'package.json' ) , 'utf-8' ) ) ;
78+ const rootPkgJsonAtTag = await exec ( `git show ${ lastTag } :package.json` )
79+ . then ( ( { stdout } ) => JSON . parse ( stdout ) )
80+ . catch ( ( ) => ( { } ) ) ;
81+
82+ const getOverrides = ( pkg ) => ( { ...pkg . pnpm ?. overrides , ...pkg . overrides } ) ;
83+
84+ const currentOverrides = getOverrides ( rootPkgJson ) ;
85+ const previousOverrides = getOverrides ( rootPkgJsonAtTag ) ;
86+
87+ const changedOverrides = new Set (
88+ Object . keys ( { ...currentOverrides , ...previousOverrides } ) . filter (
89+ ( k ) => currentOverrides [ k ] !== previousOverrides [ k ] ,
90+ ) ,
91+ ) ;
92+
93+ const parseWorkspaceYaml = ( content ) => {
94+ try {
95+ return /** @type {Record<string, unknown> } */ ( parse ( content ) ?? { } ) ;
96+ } catch {
97+ return { } ;
98+ }
99+ } ;
100+ const workspaceYaml = parseWorkspaceYaml (
101+ await readFile ( resolve ( rootDir , 'pnpm-workspace.yaml' ) , 'utf-8' ) . catch ( ( ) => '' ) ,
102+ ) ;
103+ const workspaceYamlAtTag = parseWorkspaceYaml (
104+ await exec ( `git show ${ lastTag } :pnpm-workspace.yaml` )
105+ . then ( ( { stdout } ) => stdout )
106+ . catch ( ( ) => '' ) ,
107+ ) ;
108+ const getCatalogs = ( ws ) => {
109+ const result = new Map ( ) ;
110+ if ( ws . catalog ) {
111+ result . set ( 'default' , /** @type {Record<string,string> } */ ( ws . catalog ) ) ;
112+ }
113+
114+ for ( const [ name , entries ] of Object . entries ( ws . catalogs ?? { } ) ) {
115+ result . set ( name , entries ) ;
116+ }
117+
118+ return result ;
119+ } ;
120+ // changedCatalogEntries: Map<catalogName, Set<depName>>
121+ const currentCatalogs = getCatalogs ( workspaceYaml ) ;
122+ const previousCatalogs = getCatalogs ( workspaceYamlAtTag ) ;
123+ const changedCatalogEntries = new Map ( ) ;
124+ for ( const catalogName of new Set ( [ ...currentCatalogs . keys ( ) , ...previousCatalogs . keys ( ) ] ) ) {
125+ const current = currentCatalogs . get ( catalogName ) ?? { } ;
126+ const previous = previousCatalogs . get ( catalogName ) ?? { } ;
127+ const changedDeps = new Set (
128+ Object . keys ( { ...current , ...previous } ) . filter ( ( dep ) => current [ dep ] !== previous [ dep ] ) ,
129+ ) ;
130+ if ( changedDeps . size > 0 ) {
131+ changedCatalogEntries . set ( catalogName , changedDeps ) ;
132+ }
133+ }
134+
135+ // Store full dep objects (with specifiers) so we can inspect "catalog:…" values below.
60136const depsByPackage = { } ;
61137for ( const packageName in packageMap ) {
62138 const packageFile = resolve ( packageMap [ packageName ] . path , 'package.json' ) ;
63139 const packageJson = JSON . parse ( await readFile ( packageFile , 'utf-8' ) ) ;
64- depsByPackage [ packageName ] = Object . keys ( packageJson . dependencies || { } ) ;
140+ depsByPackage [ packageName ] = /** @type {Record<string,string> } */ (
141+ packageJson . dependencies ?? { }
142+ ) ;
143+ }
144+
145+ // Mark packages dirty if any dep had a root-level override or catalog version change.
146+ for ( const [ packageName , deps ] of Object . entries ( depsByPackage ) ) {
147+ if ( packageMap [ packageName ] . isDirty ) continue ;
148+ for ( const [ dep , specifier ] of Object . entries ( deps ) ) {
149+ if ( changedOverrides . has ( dep ) ) {
150+ packageMap [ packageName ] . isDirty = true ;
151+ break ;
152+ }
153+ if ( typeof specifier === 'string' && specifier . startsWith ( 'catalog:' ) ) {
154+ const catalogName = specifier === 'catalog:' ? 'default' : specifier . slice ( 8 ) ;
155+ if ( changedCatalogEntries . get ( catalogName ) ?. has ( dep ) ) {
156+ packageMap [ packageName ] . isDirty = true ;
157+ break ;
158+ }
159+ }
160+ }
65161}
66162
67163let changed = true ;
68164while ( changed ) {
69165 changed = false ;
70166 for ( const packageName in packageMap ) {
71167 if ( packageMap [ packageName ] . isDirty ) continue ;
72- if ( depsByPackage [ packageName ] . some ( ( dep ) => packageMap [ dep ] ?. isDirty ) ) {
168+ if ( Object . keys ( depsByPackage [ packageName ] ) . some ( ( dep ) => packageMap [ dep ] ?. isDirty ) ) {
73169 packageMap [ packageName ] . isDirty = true ;
74170 changed = true ;
75171 }
0 commit comments