1616 *
1717 * Requires: gh CLI (authed). Publishing is tokenless via Trusted Publishing (OIDC) — no NPM_TOKEN.
1818 */
19- import { $ } from "bun" ;
2019import { commandInvocation } from "../src/lib/win-exec" ;
2120
2221const args = process . argv . slice ( 2 ) ;
@@ -64,6 +63,44 @@ async function runQuiet(command: string[]): Promise<CommandResult> {
6463 return { exitCode, stdout : stdout . trim ( ) , stderr : stderr . trim ( ) } ;
6564}
6665
66+ /**
67+ * Capture stdout from a command, failing loudly on a non-zero exit.
68+ *
69+ * Everything in this script goes through `commandInvocation` rather than
70+ * `Bun.$`. The shell form looked equivalent but is not on Windows: a test that
71+ * puts shims on PATH writes an extension-less launcher (shebang), a `.js`, and a
72+ * `.cmd`. Unix honours the shebang launcher; Windows cannot execute it and the
73+ * built-in shell does not retry as `.cmd`, so `$` walked past the shim straight
74+ * to the real `git` — the branch guard then saw `dev` instead of the faked
75+ * `main` and aborted before a single command was logged. That is what made four
76+ * release-helper tests fail on windows-latest only, with an empty call log.
77+ */
78+ async function capture ( command : string [ ] ) : Promise < string > {
79+ const result = await runQuiet ( command ) ;
80+ if ( result . exitCode !== 0 ) {
81+ console . error ( `✗ ${ command . join ( " " ) } failed (exit ${ result . exitCode } )` ) ;
82+ if ( result . stderr ) console . error ( result . stderr ) ;
83+ process . exit ( 1 ) ;
84+ }
85+ return result . stdout ;
86+ }
87+
88+ /** Run a command with its output attached to this terminal; abort on failure. */
89+ async function runLoud ( command : string [ ] ) : Promise < void > {
90+ const [ bin , ...rest ] = command ;
91+ const invocation = commandInvocation ( bin ?? "" , rest ) ;
92+ const proc = Bun . spawn ( [ invocation . file , ...invocation . args ] , {
93+ stdout : "inherit" ,
94+ stderr : "inherit" ,
95+ ...( invocation . options . windowsVerbatimArguments ? { windowsVerbatimArguments : true } : { } ) ,
96+ } ) ;
97+ const exitCode = await proc . exited ;
98+ if ( exitCode !== 0 ) {
99+ console . error ( `✗ ${ command . join ( " " ) } failed (exit ${ exitCode } )` ) ;
100+ process . exit ( 1 ) ;
101+ }
102+ }
103+
67104async function readPackageName ( ) : Promise < string > {
68105 try {
69106 const pkg = JSON . parse ( await Bun . file ( "package.json" ) . text ( ) ) as { name ?: unknown } ;
@@ -139,21 +176,21 @@ async function assertUnusedReleaseVersion(packageName: string, version: string):
139176}
140177
141178async function watchLatest ( ) : Promise < void > {
142- const id = ( await $ `gh run list --workflow release.yml --limit 1 --json databaseId -q ' .[0].databaseId'` . text ( ) ) . trim ( ) ;
179+ const id = await capture ( [ "gh" , " run" , " list" , " --workflow" , " release.yml" , " --limit" , "1" , " --json" , " databaseId" , "-q" , " .[0].databaseId" ] ) ;
143180 if ( ! id ) { console . error ( "No Release runs found yet." ) ; process . exit ( 1 ) ; }
144181 await watchRun ( id ) ;
145182}
146183
147184async function watchRun ( id : string | number ) : Promise < void > {
148185 console . log ( `→ watching Release run ${ id } ` ) ;
149- await $ `gh run watch ${ String ( id ) } --exit-status --interval 10` ;
186+ await runLoud ( [ "gh" , " run" , " watch" , String ( id ) , " --exit-status" , " --interval" , "10" ] ) ;
150187}
151188
152189async function waitForReleaseWorkflowRun ( sha : string , branch : string , createdAfterIso : string ) : Promise < GhRun > {
153190 const deadline = Date . now ( ) + 2 * 60 * 1000 ;
154191 let attempt = 1 ;
155192 while ( Date . now ( ) < deadline ) {
156- const raw = await $ `gh run list --workflow release.yml --branch ${ branch } --commit ${ sha } --limit 20 --json createdAt,databaseId,headSha,status,url` . text ( ) ;
193+ const raw = await capture ( [ "gh" , " run" , " list" , " --workflow" , " release.yml" , " --branch" , branch , " --commit" , sha , " --limit" , "20" , " --json" , " createdAt,databaseId,headSha,status,url" ] ) ;
157194 const runs = ( JSON . parse ( raw ) as GhRun [ ] )
158195 . filter ( run => run . headSha === sha )
159196 . filter ( run => ! run . createdAt || run . createdAt >= createdAfterIso )
@@ -172,7 +209,7 @@ async function waitForReleaseWorkflowRun(sha: string, branch: string, createdAft
172209}
173210
174211async function listCiRuns ( sha : string , workflow : string = CI_WORKFLOW ) : Promise < GhRun [ ] > {
175- const raw = await $ `gh run list --workflow ${ workflow } --commit ${ sha } --limit 20 --json conclusion,databaseId,headSha,status,url` . text ( ) ;
212+ const raw = await capture ( [ "gh" , " run" , " list" , " --workflow" , workflow , " --commit" , sha , " --limit" , "20" , " --json" , " conclusion,databaseId,headSha,status,url" ] ) ;
176213 const runs = JSON . parse ( raw ) as GhRun [ ] ;
177214 return runs . filter ( run => run . headSha === sha ) ;
178215}
@@ -207,7 +244,7 @@ async function waitForSuccessfulCi(sha: string, workflow: string = CI_WORKFLOW,
207244}
208245
209246async function _remoteMainSha ( ) : Promise < string > {
210- const out = ( await $ ` git ls-remote origin refs/heads/main` . text ( ) ) . trim ( ) ;
247+ const out = await capture ( [ " git" , " ls-remote" , " origin" , " refs/heads/main" ] ) ;
211248 const [ sha ] = out . split ( / \s + / ) ;
212249 if ( ! sha ) {
213250 console . error ( "✗ could not resolve origin/main" ) ;
@@ -218,7 +255,7 @@ async function _remoteMainSha(): Promise<string> {
218255
219256/** Live (network) head of a remote branch — never the local remote-tracking ref. */
220257async function remoteBranchHead ( branch : string ) : Promise < string > {
221- const out = ( await $ ` git ls-remote origin refs/heads/${ branch } `. text ( ) ) . trim ( ) ;
258+ const out = await capture ( [ " git" , " ls-remote" , " origin" , ` refs/heads/${ branch } `] ) ;
222259 const [ sha ] = out . split ( / \s + / ) ;
223260 if ( ! sha ) {
224261 console . error ( `✗ could not resolve origin/${ branch } ` ) ;
@@ -240,7 +277,7 @@ if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
240277const dryRun = ! args . includes ( "--publish" ) ;
241278
242279// 1. Preflight — must be on main or preview, and local verification must pass.
243- const branch = ( await $ ` git rev-parse --abbrev-ref HEAD` . text ( ) ) . trim ( ) ;
280+ const branch = await capture ( [ " git" , " rev-parse" , " --abbrev-ref" , " HEAD" ] ) ;
244281const allowedBranches = [ "main" , "preview" ] ;
245282const expectedTag = branch === "preview" ? "preview" : "latest" ;
246283const tag = args . includes ( "--tag" ) ? ( args [ args . indexOf ( "--tag" ) + 1 ] ?? expectedTag ) : expectedTag ;
@@ -257,27 +294,27 @@ if (branch === "main" && version.includes("-")) {
257294 process . exit ( 1 ) ;
258295}
259296if ( ! allowedBranches . includes ( branch ) ) { console . error ( `✗ must be on ${ allowedBranches . join ( " or " ) } (currently ${ branch } ).` ) ; process . exit ( 1 ) ; }
260- if ( ( await $ ` git status --porcelain` . text ( ) ) . trim ( ) ) { console . error ( "✗ working tree not clean — commit or stash first." ) ; process . exit ( 1 ) ; }
297+ if ( ( await capture ( [ " git" , " status" , " --porcelain" ] ) ) . trim ( ) ) { console . error ( "✗ working tree not clean — commit or stash first." ) ; process . exit ( 1 ) ; }
261298const packageName = await readPackageName ( ) ;
262299console . log ( `→ release metadata preflight (${ packageName } @${ version } )` ) ;
263300await assertUnusedReleaseVersion ( packageName , version ) ;
264301console . log ( "→ typecheck" ) ;
265- await $ ` bun x tsc --noEmit` ;
302+ await runLoud ( [ " bun" , "x" , " tsc" , " --noEmit" ] ) ;
266303console . log ( "→ test suite" ) ;
267- await $ ` bun test --isolate tests` ;
304+ await runLoud ( [ " bun" , " test" , " --isolate" , " tests" ] ) ;
268305console . log ( "→ privacy scan" ) ;
269- await $ ` bun run privacy:scan` ;
306+ await runLoud ( [ " bun" , " run" , " privacy:scan" ] ) ;
270307
271308// 2. Bump package.json only; the workflow creates the version tag after npm publish.
272309console . log ( `→ bump package.json → ${ version } ` ) ;
273- await $ ` npm version ${ version } --no-git-tag-version` ;
310+ await runLoud ( [ " npm" , " version" , version , " --no-git-tag-version" ] ) ;
274311
275312// 3. Commit + push the version bump.
276- await $ ` git add package.json` ;
277- await $ ` git commit -m ${ `release: v${ version } ` } ` ;
278- const releaseSha = ( await $ ` git rev-parse HEAD` . text ( ) ) . trim ( ) ;
313+ await runLoud ( [ " git" , " add" , " package.json" ] ) ;
314+ await runLoud ( [ " git" , " commit" , "-m" , `release: v${ version } ` ] ) ;
315+ const releaseSha = await capture ( [ " git" , " rev-parse" , " HEAD" ] ) ;
279316console . log ( `→ push origin ${ branch } ` ) ;
280- await $ ` git push origin ${ branch } ` ;
317+ await runLoud ( [ " git" , " push" , " origin" , branch ] ) ;
281318
282319// 4. Wait for the pushed release commit to pass CI, then dispatch the Release workflow.
283320console . log ( `→ wait for Cross-platform CI (${ releaseSha } )` ) ;
@@ -301,7 +338,7 @@ if (liveOriginSha !== releaseSha) {
301338
302339console . log ( `→ dispatch Release (tag=${ tag } , dry-run=${ dryRun } )` ) ;
303340const dispatchStartedAt = new Date ( Date . now ( ) - 5_000 ) . toISOString ( ) ;
304- await $ `gh workflow run release.yml --ref ${ branch } -f version=${ version } -f tag=${ tag } -f expected-sha=${ releaseSha } -f dry-run=${ String ( dryRun ) } `;
341+ await runLoud ( [ "gh" , " workflow" , " run" , " release.yml" , " --ref" , branch , "-f" , ` version=${ version } ` , "-f" , ` tag=${ tag } ` , "-f" , ` expected-sha=${ releaseSha } ` , "-f" , ` dry-run=${ String ( dryRun ) } `] ) ;
305342
306343// 5. Watch it.
307344const releaseRun = await waitForReleaseWorkflowRun ( releaseSha , branch , dispatchStartedAt ) ;
0 commit comments