11import assert from "node:assert/strict" ;
22import { execFile } from "node:child_process" ;
3- import { readFile } from "node:fs/promises" ;
3+ import { chmod , mkdir , mkdtemp , readFile , rm , writeFile } from "node:fs/promises" ;
4+ import { tmpdir } from "node:os" ;
45import path from "node:path" ;
5- import test from "node:test" ;
6+ import test , { type TestContext } from "node:test" ;
67import { promisify } from "node:util" ;
78
89const execFileAsync = promisify ( execFile ) ;
@@ -18,6 +19,154 @@ async function readCurrentVersion(): Promise<string> {
1819 return ( JSON . parse ( raw ) as { version : string } ) . version ;
1920}
2021
22+ async function writeExecutable ( filePath : string , contents : string ) : Promise < void > {
23+ await mkdir ( path . dirname ( filePath ) , { recursive : true } ) ;
24+ await writeFile ( filePath , contents , "utf8" ) ;
25+ await chmod ( filePath , 0o755 ) ;
26+ }
27+
28+ async function createReleaseHelperFixture (
29+ t : TestContext ,
30+ options : { ghReleaseViewScript : string } ,
31+ ) : Promise < { binDir : string ; logPath : string ; scriptPath : string ; tempDir : string } > {
32+ const tempDir = await mkdtemp ( path . join ( tmpdir ( ) , "ray-gh-release-helper-" ) ) ;
33+ t . after ( async ( ) => {
34+ await rm ( tempDir , { recursive : true , force : true } ) ;
35+ } ) ;
36+
37+ const fixtureScriptPath = path . join ( tempDir , "scripts" , "release" , "gh-release.sh" ) ;
38+ await mkdir ( path . dirname ( fixtureScriptPath ) , { recursive : true } ) ;
39+ await writeFile ( fixtureScriptPath , await readFile ( scriptPath , "utf8" ) , "utf8" ) ;
40+
41+ const binDir = path . join ( tempDir , "bin" ) ;
42+ const logPath = path . join ( tempDir , "commands.log" ) ;
43+
44+ await writeExecutable (
45+ path . join ( binDir , "timeout" ) ,
46+ [
47+ "#!/usr/bin/env bash" ,
48+ "set -euo pipefail" ,
49+ 'if [ "$#" -lt 2 ]; then' ,
50+ ' echo "timeout stub requires a duration and command" >&2' ,
51+ " exit 125" ,
52+ "fi" ,
53+ "shift" ,
54+ '"$@"' ,
55+ "" ,
56+ ] . join ( "\n" ) ,
57+ ) ;
58+
59+ await writeExecutable (
60+ path . join ( binDir , "bun" ) ,
61+ [
62+ "#!/usr/bin/env bash" ,
63+ "set -euo pipefail" ,
64+ 'printf "bun %s\\n" "$*" >> "${RAY_TEST_LOG:?}"' ,
65+ 'if [ "${1:-}" = "--print" ]; then' ,
66+ ' echo "1.2.3"' ,
67+ " exit 0" ,
68+ "fi" ,
69+ 'if [ "${1:-}" = "./scripts/release/check-source.mjs" ] && [ "${2:-}" = "1.2.3" ]; then' ,
70+ " exit 0" ,
71+ "fi" ,
72+ 'echo "unexpected bun invocation: $*" >&2' ,
73+ "exit 98" ,
74+ "" ,
75+ ] . join ( "\n" ) ,
76+ ) ;
77+
78+ await writeExecutable (
79+ path . join ( binDir , "git" ) ,
80+ [
81+ "#!/usr/bin/env bash" ,
82+ "set -euo pipefail" ,
83+ 'printf "git %s\\n" "$*" >> "${RAY_TEST_LOG:?}"' ,
84+ 'if [ "${1:-}" = "status" ]; then' ,
85+ " exit 0" ,
86+ "fi" ,
87+ 'if [ "${1:-}" = "branch" ] && [ "${2:-}" = "--show-current" ]; then' ,
88+ ' echo "main"' ,
89+ " exit 0" ,
90+ "fi" ,
91+ 'if [ "${1:-}" = "fetch" ]; then' ,
92+ " exit 0" ,
93+ "fi" ,
94+ 'if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "HEAD" ]; then' ,
95+ ' echo "abcdef1234567890"' ,
96+ " exit 0" ,
97+ "fi" ,
98+ 'if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "origin/main" ]; then' ,
99+ ' echo "abcdef1234567890"' ,
100+ " exit 0" ,
101+ "fi" ,
102+ 'if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "-q" ]; then' ,
103+ " exit 1" ,
104+ "fi" ,
105+ 'if [ "${1:-}" = "ls-remote" ]; then' ,
106+ " exit 2" ,
107+ "fi" ,
108+ 'if [ "${1:-}" = "tag" ] || [ "${1:-}" = "push" ]; then' ,
109+ " exit 0" ,
110+ "fi" ,
111+ 'echo "unexpected git invocation: $*" >&2' ,
112+ "exit 98" ,
113+ "" ,
114+ ] . join ( "\n" ) ,
115+ ) ;
116+
117+ await writeExecutable (
118+ path . join ( binDir , "gh" ) ,
119+ [
120+ "#!/usr/bin/env bash" ,
121+ "set -euo pipefail" ,
122+ 'printf "gh %s\\n" "$*" >> "${RAY_TEST_LOG:?}"' ,
123+ 'if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then' ,
124+ " exit 0" ,
125+ "fi" ,
126+ 'if [ "${1:-}" = "release" ] && [ "${2:-}" = "view" ]; then' ,
127+ options . ghReleaseViewScript ,
128+ "fi" ,
129+ 'if [ "${1:-}" = "release" ] && [ "${2:-}" = "create" ]; then' ,
130+ " exit 0" ,
131+ "fi" ,
132+ 'echo "unexpected gh invocation: $*" >&2' ,
133+ "exit 98" ,
134+ "" ,
135+ ] . join ( "\n" ) ,
136+ ) ;
137+
138+ return { binDir, logPath, scriptPath : fixtureScriptPath , tempDir } ;
139+ }
140+
141+ async function runFixtureReleaseHelper ( fixture : {
142+ binDir : string ;
143+ logPath : string ;
144+ scriptPath : string ;
145+ tempDir : string ;
146+ } ) : Promise < { code : number ; stderr : string ; stdout : string } > {
147+ try {
148+ const { stderr, stdout } = await execFileAsync ( "bash" , [ fixture . scriptPath , "--yes" ] , {
149+ cwd : fixture . tempDir ,
150+ env : {
151+ ...process . env ,
152+ PATH : `${ fixture . binDir } ${ path . delimiter } ${ process . env . PATH ?? "" } ` ,
153+ RAY_TEST_LOG : fixture . logPath ,
154+ } ,
155+ maxBuffer : 64 * 1024 ,
156+ timeout : 5_000 ,
157+ } ) ;
158+
159+ return { code : 0 , stderr, stdout } ;
160+ } catch ( error ) {
161+ const result = error as { code ?: number | string ; stderr ?: string ; stdout ?: string } ;
162+ return {
163+ code : typeof result . code === "number" ? result . code : 1 ,
164+ stderr : result . stderr ?? "" ,
165+ stdout : result . stdout ?? "" ,
166+ } ;
167+ }
168+ }
169+
21170test ( "gh release helper validates syntax and dry-runs without mutating git state" , async ( ) => {
22171 const version = await readCurrentVersion ( ) ;
23172 const escapedVersion = escapeRegExp ( version ) ;
@@ -46,6 +195,10 @@ test("gh release helper gates destructive releases on clean synced main", async
46195 assert . match ( contents , / r e f s \/ h e a d s \/ m a i n : r e f s \/ r e m o t e s \/ o r i g i n \/ m a i n / ) ;
47196 assert . match ( contents , / g i t r e v - p a r s e H E A D / ) ;
48197 assert . match ( contents , / g i t r e v - p a r s e o r i g i n \/ m a i n / ) ;
198+ assert . match (
199+ contents ,
200+ / r u n _ r e q u i r e d _ b o u n d e d " c h e c k i n g G i t H u b C L I a u t h e n t i c a t i o n " 6 0 g h a u t h s t a t u s / ,
201+ ) ;
49202 assert . match ( contents , / r e m o t e _ t a g _ e x i s t s " \$ t a g " / ) ;
50203 assert . match ( contents , / g i t h u b _ r e l e a s e _ e x i s t s " \$ t a g " / ) ;
51204 assert . match ( contents , / b u n \. \/ s c r i p t s \/ r e l e a s e \/ c h e c k - s o u r c e \. m j s " \$ V E R " / ) ;
@@ -55,18 +208,72 @@ test("gh release helper bounds network release operations", async () => {
55208 const contents = await readFile ( scriptPath , "utf8" ) ;
56209
57210 assert . match ( contents , / r u n _ b o u n d e d \( \) \{ / ) ;
211+ assert . match ( contents , / r u n _ r e q u i r e d _ b o u n d e d \( \) \{ / ) ;
58212 assert . match ( contents , / t i m e o u t " \$ \{ s e c o n d s \} s " " \$ @ " / ) ;
59213 assert . match ( contents , / r e t u r n 1 2 4 / ) ;
60- assert . match (
61- contents ,
62- / r u n _ b o u n d e d 1 2 0 g i t f e t c h - - t a g s o r i g i n r e f s \/ h e a d s \/ m a i n : r e f s \/ r e m o t e s \/ o r i g i n \/ m a i n / ,
63- ) ;
214+ assert . match ( contents , / g i t f e t c h - - t a g s o r i g i n r e f s \/ h e a d s \/ m a i n : r e f s \/ r e m o t e s \/ o r i g i n \/ m a i n / ) ;
64215 assert . match ( contents , / r u n _ b o u n d e d 6 0 g i t l s - r e m o t e - - e x i t - c o d e - - t a g s o r i g i n / ) ;
65216 assert . match ( contents , / f a i l " c o u l d n o t c h e c k r e m o t e t a g \$ t a g \( e x i t \$ s t a t u s \) " / ) ;
66217 assert . match ( contents , / r u n _ b o u n d e d 6 0 g h r e l e a s e v i e w " \$ t a g " / ) ;
67218 assert . match ( contents , / f a i l " t i m e d o u t c h e c k i n g G i t H u b r e l e a s e : \$ t a g " / ) ;
68- assert . match ( contents , / r u n _ b o u n d e d 6 0 g h a u t h s t a t u s / ) ;
69- assert . match ( contents , / r u n _ b o u n d e d 1 2 0 g i t p u s h o r i g i n " \$ T A G _ C O R E " " \$ T A G _ S D K " / ) ;
70- assert . match ( contents , / r u n _ b o u n d e d 1 2 0 g h r e l e a s e c r e a t e " \$ T A G _ C O R E " / ) ;
71- assert . match ( contents , / r u n _ b o u n d e d 1 2 0 g h r e l e a s e c r e a t e " \$ T A G _ S D K " / ) ;
219+ assert . match (
220+ contents ,
221+ / f a i l " c o u l d n o t c h e c k G i t H u b r e l e a s e \$ t a g \( e x i t \$ s t a t u s \) : \$ m e s s a g e " / ,
222+ ) ;
223+ assert . match (
224+ contents ,
225+ / r u n _ r e q u i r e d _ b o u n d e d " c h e c k i n g G i t H u b C L I a u t h e n t i c a t i o n " 6 0 g h a u t h s t a t u s / ,
226+ ) ;
227+ assert . match ( contents , / r u n _ r e q u i r e d _ b o u n d e d " p u s h i n g r e l e a s e t a g s " 1 2 0 g i t p u s h o r i g i n / ) ;
228+ assert . match ( contents , / " c r e a t i n g G i t H u b r e l e a s e \$ T A G _ C O R E " / ) ;
229+ assert . match ( contents , / " c r e a t i n g G i t H u b r e l e a s e \$ T A G _ S D K " / ) ;
230+ } ) ;
231+
232+ test ( "gh release helper aborts ambiguous GitHub release probe failures before tagging" , async ( t ) => {
233+ const fixture = await createReleaseHelperFixture ( t , {
234+ ghReleaseViewScript : [ ' echo "api rate limit exceeded" >&2' , " exit 1" ] . join ( "\n" ) ,
235+ } ) ;
236+
237+ const result = await runFixtureReleaseHelper ( fixture ) ;
238+
239+ assert . notEqual ( result . code , 0 ) ;
240+ assert . match (
241+ result . stderr ,
242+ / c o u l d n o t c h e c k G i t H u b r e l e a s e c o r e - v 1 \. 2 \. 3 \( e x i t 1 \) : a p i r a t e l i m i t e x c e e d e d / ,
243+ ) ;
244+
245+ const commandLog = await readFile ( fixture . logPath , "utf8" ) ;
246+ assert . match ( commandLog , / ^ g h a u t h s t a t u s $ / m) ;
247+ assert . match ( commandLog , / ^ g h r e l e a s e v i e w c o r e - v 1 \. 2 \. 3 $ / m) ;
248+ assert . doesNotMatch ( commandLog , / ^ g i t t a g / m) ;
249+ assert . doesNotMatch ( commandLog , / ^ g i t p u s h / m) ;
250+ assert . doesNotMatch ( commandLog , / ^ g h r e l e a s e c r e a t e / m) ;
251+ } ) ;
252+
253+ test ( "gh release helper continues when GitHub reports releases are missing" , async ( t ) => {
254+ const fixture = await createReleaseHelperFixture ( t , {
255+ ghReleaseViewScript : [ ' echo "release not found" >&2' , " exit 1" ] . join ( "\n" ) ,
256+ } ) ;
257+
258+ const result = await runFixtureReleaseHelper ( fixture ) ;
259+
260+ assert . equal ( result . code , 0 ) ;
261+ assert . match (
262+ result . stdout ,
263+ / D o n e \. A c t i o n s p u b l i s h t o n p m w h e n e a c h r e l e a s e i s i n p u b l i s h e d s t a t e \. / ,
264+ ) ;
265+
266+ const commandLog = await readFile ( fixture . logPath , "utf8" ) ;
267+ assert . match ( commandLog , / ^ g h a u t h s t a t u s $ / m) ;
268+ assert . match ( commandLog , / ^ g i t t a g - a c o r e - v 1 \. 2 \. 3 / m) ;
269+ assert . match ( commandLog , / ^ g i t t a g - a s d k - v 1 \. 2 \. 3 / m) ;
270+ assert . match ( commandLog , / ^ g i t p u s h o r i g i n c o r e - v 1 \. 2 \. 3 s d k - v 1 \. 2 \. 3 $ / m) ;
271+ assert . match (
272+ commandLog ,
273+ / ^ g h r e l e a s e c r e a t e c o r e - v 1 \. 2 \. 3 - - g e n e r a t e - n o t e s - - t i t l e c o r e - v 1 \. 2 \. 3 $ / m,
274+ ) ;
275+ assert . match (
276+ commandLog ,
277+ / ^ g h r e l e a s e c r e a t e s d k - v 1 \. 2 \. 3 - - g e n e r a t e - n o t e s - - t i t l e s d k - v 1 \. 2 \. 3 $ / m,
278+ ) ;
72279} ) ;
0 commit comments