1- import { spawn } from 'node:child_process' ;
1+ import { spawn , spawnSync } from 'node:child_process' ;
22import { readFileSync , writeFileSync } from 'node:fs' ;
33import { join } from 'node:path' ;
44import { globSync } from 'glob' ;
55
66const repoRoot = process . cwd ( ) ;
77const depSections = [ 'dependencies' , 'peerDependencies' , 'optionalDependencies' , 'devDependencies' ] ;
8+ const publishAttempts = Number ( process . env . RELEASE_PUBLISH_ATTEMPTS || 2 ) ;
9+ const explicitPackages = ( process . env . RELEASE_PACKAGES || '' )
10+ . split ( ',' )
11+ . map ( ( s ) => s . trim ( ) )
12+ . filter ( Boolean ) ;
813
914const rootPackage = JSON . parse ( readFileSync ( join ( repoRoot , 'package.json' ) , 'utf8' ) ) ;
1015const workspacePatterns = Array . isArray ( rootPackage . workspaces ) ? rootPackage . workspaces : [ ] ;
@@ -116,43 +121,162 @@ const restoreWorkspaceRanges = () => {
116121
117122const sleep = ( ms ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
118123
119- const runChangesetPublishOnce = ( ) =>
124+ const run = ( cmd , args , options = { } ) => {
125+ const result = spawnSync ( cmd , args , {
126+ cwd : repoRoot ,
127+ encoding : 'utf8' ,
128+ ...options ,
129+ } ) ;
130+ if ( result . error ) throw result . error ;
131+ return result ;
132+ } ;
133+
134+ const toLines = ( value ) =>
135+ String ( value || '' )
136+ . split ( '\n' )
137+ . map ( ( line ) => line . trim ( ) )
138+ . filter ( Boolean ) ;
139+
140+ const getCurrentPackageInfo = ( packageJsonPath ) => {
141+ const pkg = JSON . parse ( readFileSync ( packageJsonPath , 'utf8' ) ) ;
142+ return {
143+ name : pkg ?. name ,
144+ version : pkg ?. version ,
145+ private : pkg ?. private === true ,
146+ } ;
147+ } ;
148+
149+ const getPackageInfoAtRef = ( ref , packageJsonPathRelative ) => {
150+ const result = run ( 'git' , [ 'show' , `${ ref } :${ packageJsonPathRelative } ` ] ) ;
151+ if ( result . status !== 0 ) return null ;
152+ try {
153+ const pkg = JSON . parse ( result . stdout ) ;
154+ return { name : pkg ?. name , version : pkg ?. version , private : pkg ?. private === true } ;
155+ } catch {
156+ return null ;
157+ }
158+ } ;
159+
160+ const listChangedPackageJsons = ( ) => {
161+ const unstaged = run ( 'git' , [
162+ 'diff' ,
163+ '--name-only' ,
164+ '--' ,
165+ ':(glob)packages/**/package.json' ,
166+ ':(glob)tools/**/package.json' ,
167+ ] ) ;
168+ const staged = run ( 'git' , [
169+ 'diff' ,
170+ '--name-only' ,
171+ '--cached' ,
172+ '--' ,
173+ ':(glob)packages/**/package.json' ,
174+ ':(glob)tools/**/package.json' ,
175+ ] ) ;
176+
177+ const paths = [ ...toLines ( unstaged . stdout ) , ...toLines ( staged . stdout ) ] ;
178+ return [ ...new Set ( paths ) ] ;
179+ } ;
180+
181+ const listVersionBumpedPackages = ( ) => {
182+ const candidates = listChangedPackageJsons ( ) ;
183+ const bumped = new Map ( ) ;
184+
185+ for ( const relativePath of candidates ) {
186+ const absolutePath = join ( repoRoot , relativePath ) ;
187+ const current = getCurrentPackageInfo ( absolutePath ) ;
188+ const atHead = getPackageInfoAtRef ( 'HEAD' , relativePath ) ;
189+ if ( ! current ?. name || current . private ) continue ;
190+ if ( ! atHead || atHead . version !== current . version ) {
191+ bumped . set ( current . name , current . version ) ;
192+ }
193+ }
194+
195+ if ( bumped . size > 0 ) return bumped ;
196+
197+ // Fallback for CI publish commits: compare HEAD~1..HEAD
198+ const rangeDiff = run ( 'git' , [
199+ 'diff' ,
200+ '--name-only' ,
201+ 'HEAD~1..HEAD' ,
202+ '--' ,
203+ ':(glob)packages/**/package.json' ,
204+ ':(glob)tools/**/package.json' ,
205+ ] ) ;
206+ const rangePaths = [ ...new Set ( toLines ( rangeDiff . stdout ) ) ] ;
207+
208+ for ( const relativePath of rangePaths ) {
209+ const current = getPackageInfoAtRef ( 'HEAD' , relativePath ) ;
210+ const previous = getPackageInfoAtRef ( 'HEAD~1' , relativePath ) ;
211+ if ( ! current ?. name || current . private ) continue ;
212+ if ( ! previous || previous . version !== current . version ) {
213+ bumped . set ( current . name , current . version ) ;
214+ }
215+ }
216+
217+ return bumped ;
218+ } ;
219+
220+ const resolveExplicitPackages = ( ) => {
221+ const selected = new Map ( ) ;
222+ for ( const packageJsonPath of packageJsonPaths ) {
223+ const pkg = JSON . parse ( readFileSync ( packageJsonPath , 'utf8' ) ) ;
224+ if ( ! pkg ?. name || pkg . private === true ) continue ;
225+ if ( explicitPackages . includes ( pkg . name ) ) {
226+ selected . set ( pkg . name , pkg . version ) ;
227+ }
228+ }
229+ return selected ;
230+ } ;
231+
232+ const getPublishedVersion = ( packageName ) => {
233+ const result = run ( 'npm' , [ 'view' , packageName , 'version' , '--json' ] ) ;
234+ if ( result . status !== 0 ) return null ;
235+ const text = String ( result . stdout || '' ) . trim ( ) ;
236+ if ( ! text ) return null ;
237+ try {
238+ const parsed = JSON . parse ( text ) ;
239+ if ( Array . isArray ( parsed ) ) return parsed [ parsed . length - 1 ] || null ;
240+ return parsed || null ;
241+ } catch {
242+ return text || null ;
243+ }
244+ } ;
245+
246+ const publishWorkspaceOnce = ( packageName ) =>
120247 new Promise ( ( resolve , reject ) => {
121- const child = spawn ( 'bunx ' , [ 'changeset ' , 'publish ' ] , {
248+ const child = spawn ( 'npm ' , [ 'publish ' , '--workspace' , packageName , '--access' , 'public '] , {
122249 cwd : repoRoot ,
123250 stdio : 'inherit' ,
124251 env : process . env ,
125252 } ) ;
126253
127254 child . on ( 'close' , ( code ) => {
128255 if ( code === 0 ) resolve ( ) ;
129- else reject ( new Error ( `changeset publish exited with code ${ code } ` ) ) ;
256+ else reject ( new Error ( `npm publish failed for ${ packageName } with code ${ code } ` ) ) ;
130257 } ) ;
131258 child . on ( 'error' , reject ) ;
132259 } ) ;
133260
134- const runChangesetPublish = async ( ) => {
135- // Retry once to recover from transient npm issues or partial publishes.
136- // On retry, Changesets skips versions that are already published.
137- const maxAttempts = 2 ;
261+ const publishWorkspaceWithRetry = async ( packageName ) => {
138262 let lastError ;
139263
140- for ( let attempt = 1 ; attempt <= maxAttempts ; attempt ++ ) {
264+ for ( let attempt = 1 ; attempt <= publishAttempts ; attempt ++ ) {
141265 try {
142- console . log ( `[release] Running changeset publish (attempt ${ attempt } /${ maxAttempts } )` ) ;
143- await runChangesetPublishOnce ( ) ;
266+ console . log ( `[release] Publishing ${ packageName } (attempt ${ attempt } /${ publishAttempts } )` ) ;
267+ await publishWorkspaceOnce ( packageName ) ;
144268 return ;
145269 } catch ( error ) {
146270 lastError = error ;
147- if ( attempt === maxAttempts ) break ;
271+ if ( attempt === publishAttempts ) break ;
148272 console . warn (
149- `[release] changeset publish failed on attempt ${ attempt } ; retrying once in 5s...`
273+ `[release] publish failed for ${ packageName } on attempt ${ attempt } ; retrying in 5s...`
150274 ) ;
151275 await sleep ( 5000 ) ;
152276 }
153277 }
154278
155- throw lastError ;
279+ throw lastError || new Error ( `publish failed for ${ packageName } ` ) ;
156280} ;
157281
158282try {
@@ -176,7 +300,34 @@ try {
176300 ) ;
177301 }
178302
179- await runChangesetPublish ( ) ;
303+ const targetPackages =
304+ explicitPackages . length > 0 ? resolveExplicitPackages ( ) : listVersionBumpedPackages ( ) ;
305+
306+ if ( targetPackages . size === 0 ) {
307+ const explicitHint =
308+ explicitPackages . length > 0
309+ ? ` (requested RELEASE_PACKAGES=${ explicitPackages . join ( ',' ) } )`
310+ : '' ;
311+ throw new Error (
312+ `[release] No version-bumped publish targets found${ explicitHint } . Refusing to publish all packages.`
313+ ) ;
314+ }
315+
316+ const packageList = [ ...targetPackages . entries ( ) ] . map ( ( [ name , version ] ) => ( { name, version } ) ) ;
317+ console . log (
318+ `[release] Selected publish targets (${ packageList . length } ): ${ packageList
319+ . map ( ( p ) => `${ p . name } @${ p . version } ` )
320+ . join ( ', ' ) } `
321+ ) ;
322+
323+ for ( const { name, version } of packageList ) {
324+ const published = getPublishedVersion ( name ) ;
325+ if ( published === version ) {
326+ console . log ( `[release] Skipping ${ name } @${ version } (already published)` ) ;
327+ continue ;
328+ }
329+ await publishWorkspaceWithRetry ( name ) ;
330+ }
180331} finally {
181332 restoreWorkspaceRanges ( ) ;
182333 if ( changedFiles . length > 0 ) {
0 commit comments