77 joinStrings , findUp , getFileChanges , write ,
88 readVersionFromPackageJson , readVersionFromPyprojectToml ,
99 removeIgnoredFiles , getGithubTokens , getGiteaTokens ,
10- getRepoInfo , writeResult , createForgeRelease ,
10+ getRepoInfo , writeResult , createForgeRelease , deleteForgeRelease ,
1111 readChangelogEntry , updateChangelogHeadingDate ,
1212 type RepoInfo ,
1313} from "./index.ts" ;
@@ -440,40 +440,82 @@ test("updateChangelogHeadingDate", () => {
440440 expect ( updateChangelogHeadingDate ( "## 1.0.0\nbody\n" , "9.9.9" , today ) ) . toBeNull ( ) ;
441441} ) ;
442442
443- test ( "createForgeRelease github success" , async ( ) => {
444- const mock = vi . fn ( ( ) => Promise . resolve ( Response . json ( { html_url : "https://github.com/o/r/releases/tag/1.0.1" } , { status : 201 } ) ) ) ;
443+ function getCalls ( mock : ReturnType < typeof vi . fn > ) {
444+ return mock . mock . calls as unknown as Array < [ string , RequestInit | undefined ] > ;
445+ }
446+
447+ function postCall ( mock : ReturnType < typeof vi . fn > ) {
448+ const found = getCalls ( mock ) . find ( ( [ , init ] ) => init ?. method === "POST" ) ;
449+ if ( ! found ) throw new Error ( "no POST call recorded" ) ;
450+ return found ;
451+ }
452+
453+ function authOf ( init : RequestInit | undefined ) {
454+ return ( init ?. headers as Record < string , string > | undefined ) ?. Authorization ;
455+ }
456+
457+ function mockForgePost ( create : Response | ( ( ) => Response ) ) {
458+ const mock = vi . fn ( ( ) => Promise . resolve ( typeof create === "function" ? create ( ) : create ) ) ;
459+ stubGlobal ( "fetch" , mock ) ;
460+ return mock ;
461+ }
462+
463+ // First POST returns conflictStatus; cleanup GET returns drafts; DELETE 204; retry POST returns success.
464+ function mockForgeConflictThenSuccess ( conflictStatus : number , drafts : Array < { id : number ; tag_name : string ; draft : boolean } > , success : Response ) {
465+ let postCount = 0 ;
466+ const mock = vi . fn ( ( _url : string , init ?: RequestInit ) => {
467+ const method = init ?. method ?? "GET" ;
468+ if ( method === "POST" ) {
469+ postCount += 1 ;
470+ if ( postCount === 1 ) return Promise . resolve ( new Response ( `conflict ${ conflictStatus } ` , { status : conflictStatus , statusText : "Conflict" } ) ) ;
471+ return Promise . resolve ( success ) ;
472+ }
473+ if ( method === "GET" ) return Promise . resolve ( Response . json ( drafts , { status : 200 } ) ) ;
474+ if ( method === "DELETE" ) return Promise . resolve ( new Response ( null , { status : 204 } ) ) ;
475+ throw new Error ( `unexpected method ${ method } ` ) ;
476+ } ) ;
445477 stubGlobal ( "fetch" , mock ) ;
478+ return mock ;
479+ }
480+
481+ test ( "createForgeRelease github success skips cleanup on happy path" , async ( ) => {
482+ const mock = mockForgePost ( Response . json ( { id : 4242 , html_url : "https://github.com/o/r/releases/tag/1.0.1" } , { status : 201 } ) ) ;
446483 const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
447- await createForgeRelease ( info , "1.0.1" , "changelog" , [ "gh-token" ] ) ;
484+ const created = await createForgeRelease ( info , "1.0.1" , "changelog" , [ "gh-token" ] ) ;
485+ expect ( created ) . toEqual ( { id : 4242 , html_url : "https://github.com/o/r/releases/tag/1.0.1" } ) ;
448486 expect ( mock ) . toHaveBeenCalledOnce ( ) ;
449- const [ url , init ] = mock . mock . calls [ 0 ] as unknown as [ string , any ] ;
487+ const [ url , init ] = postCall ( mock ) ;
450488 expect ( url ) . toEqual ( "https://api.github.com/repos/o/r/releases" ) ;
451- expect ( init . headers . Authorization ) . toEqual ( "Bearer gh-token" ) ;
452- const body = JSON . parse ( init . body ) ;
489+ expect ( authOf ( init ) ) . toEqual ( "Bearer gh-token" ) ;
490+ const body = JSON . parse ( init ! . body as string ) ;
453491 expect ( body . tag_name ) . toEqual ( "1.0.1" ) ;
454492 expect ( body . name ) . toEqual ( "1.0.1" ) ;
455493 expect ( body . body ) . toEqual ( "changelog" ) ;
456494 expect ( body . draft ) . toEqual ( false ) ;
457495 expect ( body . prerelease ) . toEqual ( false ) ;
458496} ) ;
459497
460- test ( "createForgeRelease gitea success" , async ( ) => {
461- const mock = vi . fn ( ( ) => Promise . resolve ( Response . json ( { html_url : "https://gitea.example.com/o/r/releases/tag/2.0.0" } , { status : 201 } ) ) ) ;
462- stubGlobal ( "fetch" , mock ) ;
498+ test ( "createForgeRelease returns null when response has no numeric id" , async ( ) => {
499+ mockForgePost ( Response . json ( { html_url : "https://github.com/o/r/releases/tag/1.0.0" } , { status : 201 } ) ) ;
500+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
501+ expect ( await createForgeRelease ( info , "1.0.0" , "body" , [ "tok" ] ) ) . toBeNull ( ) ;
502+ } ) ;
503+
504+ test ( "createForgeRelease gitea success skips cleanup on happy path" , async ( ) => {
505+ const mock = mockForgePost ( Response . json ( { html_url : "https://gitea.example.com/o/r/releases/tag/2.0.0" } , { status : 201 } ) ) ;
463506 const info : RepoInfo = { owner : "o" , repo : "r" , host : "gitea.example.com" , type : "gitea" } ;
464507 await createForgeRelease ( info , "2.0.0" , "notes" , [ "gitea-tok" ] ) ;
465508 expect ( mock ) . toHaveBeenCalledOnce ( ) ;
466- const [ url , init ] = mock . mock . calls [ 0 ] as unknown as [ string , any ] ;
509+ const [ url , init ] = postCall ( mock ) ;
467510 expect ( url ) . toEqual ( "https://gitea.example.com/api/v1/repos/o/r/releases" ) ;
468- expect ( init . headers . Authorization ) . toEqual ( "token gitea-tok" ) ;
511+ expect ( authOf ( init ) ) . toEqual ( "token gitea-tok" ) ;
469512} ) ;
470513
471514test ( "createForgeRelease prerelease tag" , async ( ) => {
472- const mock = vi . fn ( ( ) => Promise . resolve ( Response . json ( { } , { status : 201 } ) ) ) ;
473- stubGlobal ( "fetch" , mock ) ;
515+ const mock = mockForgePost ( Response . json ( { } , { status : 201 } ) ) ;
474516 const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
475517 await createForgeRelease ( info , "1.0.0-beta.1" , "body" , [ "tok" ] ) ;
476- expect ( JSON . parse ( ( mock . mock . calls [ 0 ] as unknown as [ string , any ] ) [ 1 ] . body ) . prerelease ) . toEqual ( true ) ;
518+ expect ( JSON . parse ( postCall ( mock ) [ 1 ] ! . body as string ) . prerelease ) . toEqual ( true ) ;
477519} ) ;
478520
479521test . each ( [ [ 401 , "Unauthorized" ] , [ 403 , "Forbidden" ] ] ) ( "createForgeRelease token fallback on %i" , async ( status , text ) => {
@@ -486,10 +528,10 @@ test.each([[401, "Unauthorized"], [403, "Forbidden"]])("createForgeRelease token
486528 expect ( mock ) . toHaveBeenCalledTimes ( 2 ) ;
487529} ) ;
488530
489- test ( "createForgeRelease throws on non-auth error" , async ( ) => {
490- stubGlobal ( "fetch" , vi . fn ( ( ) => Promise . resolve ( new Response ( "Validation failed " , { status : 422 , statusText : "Unprocessable Entity " } ) ) ) ) ;
531+ test ( "createForgeRelease throws on non-conflict, non- auth error" , async ( ) => {
532+ stubGlobal ( "fetch" , vi . fn ( ( ) => Promise . resolve ( new Response ( "Server error " , { status : 500 , statusText : "Internal Server Error " } ) ) ) ) ;
491533 const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
492- await expect ( createForgeRelease ( info , "1.0.0" , "body" , [ "tok" ] ) ) . rejects . toThrow ( "422 " ) ;
534+ await expect ( createForgeRelease ( info , "1.0.0" , "body" , [ "tok" ] ) ) . rejects . toThrow ( "500 " ) ;
493535} ) ;
494536
495537test ( "createForgeRelease throws when all tokens fail" , async ( ) => {
@@ -507,11 +549,179 @@ test("createForgeRelease network error includes cause", async () => {
507549} ) ;
508550
509551test ( "createForgeRelease no html_url in response" , async ( ) => {
510- stubGlobal ( "fetch" , vi . fn ( ( ) => Promise . resolve ( Response . json ( { id : 1 } , { status : 201 } ) ) ) ) ;
552+ mockForgePost ( Response . json ( { id : 1 } , { status : 201 } ) ) ;
511553 const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
512554 await createForgeRelease ( info , "1.0.0" , "body" , [ "tok" ] ) ;
513555} ) ;
514556
557+ test ( "createForgeRelease cleans up draft on gitea 409 then retries" , async ( ) => {
558+ const mock = mockForgeConflictThenSuccess (
559+ 409 ,
560+ [
561+ { id : 35141 , tag_name : "v1.2.3" , draft : true } ,
562+ { id : 35142 , tag_name : "v1.2.4" , draft : true } ,
563+ { id : 35143 , tag_name : "v1.2.3" , draft : false } ,
564+ ] ,
565+ Response . json ( { html_url : "https://gitea.example.com/o/r/releases/tag/v1.2.3" } , { status : 201 } ) ,
566+ ) ;
567+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "gitea.example.com" , type : "gitea" } ;
568+ await createForgeRelease ( info , "v1.2.3" , "notes" , [ "tok" ] ) ;
569+
570+ const calls = getCalls ( mock ) ;
571+ const methods = calls . map ( ( [ , init ] ) => init ?. method ?? "GET" ) ;
572+ expect ( methods ) . toEqual ( [ "POST" , "GET" , "DELETE" , "POST" ] ) ;
573+ const deleteCall = calls . find ( ( [ , init ] ) => init ?. method === "DELETE" ) ! ;
574+ expect ( deleteCall [ 0 ] ) . toEqual ( "https://gitea.example.com/api/v1/repos/o/r/releases/35141" ) ;
575+ } ) ;
576+
577+ test ( "createForgeRelease cleans up draft on gitea 422 then retries" , async ( ) => {
578+ const mock = mockForgeConflictThenSuccess (
579+ 422 ,
580+ [ { id : 7 , tag_name : "v9.9.9" , draft : true } ] ,
581+ Response . json ( { html_url : "https://gitea.example.com/o/r/releases/tag/v9.9.9" } , { status : 201 } ) ,
582+ ) ;
583+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "gitea.example.com" , type : "gitea" } ;
584+ await createForgeRelease ( info , "v9.9.9" , "body" , [ "tok" ] ) ;
585+
586+ const methods = getCalls ( mock ) . map ( ( [ , init ] ) => init ?. method ?? "GET" ) ;
587+ expect ( methods ) . toEqual ( [ "POST" , "GET" , "DELETE" , "POST" ] ) ;
588+ } ) ;
589+
590+ test ( "createForgeRelease cleans up draft on github 422 then retries" , async ( ) => {
591+ const mock = mockForgeConflictThenSuccess (
592+ 422 ,
593+ [ { id : 99 , tag_name : "v1.0.0" , draft : true } ] ,
594+ Response . json ( { html_url : "https://github.com/o/r/releases/tag/v1.0.0" } , { status : 201 } ) ,
595+ ) ;
596+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
597+ await createForgeRelease ( info , "v1.0.0" , "body" , [ "tok" ] ) ;
598+
599+ const calls = getCalls ( mock ) ;
600+ const deleteCall = calls . find ( ( [ , init ] ) => init ?. method === "DELETE" ) ! ;
601+ expect ( deleteCall [ 0 ] ) . toEqual ( "https://api.github.com/repos/o/r/releases/99" ) ;
602+ expect ( authOf ( deleteCall [ 1 ] ) ) . toEqual ( "Bearer tok" ) ;
603+ } ) ;
604+
605+ test ( "createForgeRelease propagates conflict when no matching draft to clean up" , async ( ) => {
606+ const mock = vi . fn ( ( _url : string , init ?: RequestInit ) => {
607+ const method = init ?. method ?? "GET" ;
608+ if ( method === "POST" ) return Promise . resolve ( new Response ( "Release is has no Tag" , { status : 409 , statusText : "Conflict" } ) ) ;
609+ if ( method === "GET" ) return Promise . resolve ( Response . json ( [ { id : 1 , tag_name : "other-tag" , draft : true } ] , { status : 200 } ) ) ;
610+ throw new Error ( `unexpected method ${ method } ` ) ;
611+ } ) ;
612+ stubGlobal ( "fetch" , mock ) ;
613+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "gitea.example.com" , type : "gitea" } ;
614+ await expect ( createForgeRelease ( info , "v1.0.0" , "body" , [ "tok" ] ) ) . rejects . toThrow ( "409" ) ;
615+ const methods = getCalls ( mock ) . map ( ( [ , init ] ) => init ?. method ?? "GET" ) ;
616+ expect ( methods ) . toEqual ( [ "POST" , "GET" ] ) ;
617+ } ) ;
618+
619+ test ( "createForgeRelease cleans up multiple matching drafts" , async ( ) => {
620+ const mock = mockForgeConflictThenSuccess (
621+ 409 ,
622+ [
623+ { id : 10 , tag_name : "v1.0.0" , draft : true } ,
624+ { id : 11 , tag_name : "v1.0.0" , draft : true } ,
625+ ] ,
626+ Response . json ( { html_url : "https://gitea.example.com/o/r/releases/tag/v1.0.0" } , { status : 201 } ) ,
627+ ) ;
628+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "gitea.example.com" , type : "gitea" } ;
629+ await createForgeRelease ( info , "v1.0.0" , "body" , [ "tok" ] ) ;
630+
631+ const deleteCalls = getCalls ( mock ) . filter ( ( [ , init ] ) => init ?. method === "DELETE" ) ;
632+ expect ( deleteCalls ) . toHaveLength ( 2 ) ;
633+ } ) ;
634+
635+ test ( "createForgeRelease tolerates 404 on draft delete (already gone)" , async ( ) => {
636+ let postCount = 0 ;
637+ const mock = vi . fn ( ( _url : string , init ?: RequestInit ) => {
638+ const method = init ?. method ?? "GET" ;
639+ if ( method === "POST" ) {
640+ postCount += 1 ;
641+ if ( postCount === 1 ) return Promise . resolve ( new Response ( "conflict" , { status : 409 , statusText : "Conflict" } ) ) ;
642+ return Promise . resolve ( Response . json ( { } , { status : 201 } ) ) ;
643+ }
644+ if ( method === "GET" ) return Promise . resolve ( Response . json ( [ { id : 5 , tag_name : "v1.0.0" , draft : true } ] , { status : 200 } ) ) ;
645+ if ( method === "DELETE" ) return Promise . resolve ( new Response ( "not found" , { status : 404 } ) ) ;
646+ throw new Error ( `unexpected method ${ method } ` ) ;
647+ } ) ;
648+ stubGlobal ( "fetch" , mock ) ;
649+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
650+ await createForgeRelease ( info , "v1.0.0" , "body" , [ "tok" ] ) ;
651+ const methods = getCalls ( mock ) . map ( ( [ , init ] ) => init ?. method ?? "GET" ) ;
652+ expect ( methods ) . toEqual ( [ "POST" , "GET" , "DELETE" , "POST" ] ) ;
653+ } ) ;
654+
655+ test ( "createForgeRelease throws if draft delete fails non-404" , async ( ) => {
656+ stubGlobal ( "fetch" , vi . fn ( ( _url : string , init ?: RequestInit ) => {
657+ const method = init ?. method ?? "GET" ;
658+ if ( method === "POST" ) return Promise . resolve ( new Response ( "conflict" , { status : 409 , statusText : "Conflict" } ) ) ;
659+ if ( method === "GET" ) return Promise . resolve ( Response . json ( [ { id : 5 , tag_name : "v1.0.0" , draft : true } ] , { status : 200 } ) ) ;
660+ if ( method === "DELETE" ) return Promise . resolve ( new Response ( "server error" , { status : 500 , statusText : "Internal Server Error" } ) ) ;
661+ throw new Error ( `unexpected method ${ method } ` ) ;
662+ } ) ) ;
663+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
664+ await expect ( createForgeRelease ( info , "v1.0.0" , "body" , [ "tok" ] ) ) . rejects . toThrow ( "Failed to delete draft release 5" ) ;
665+ } ) ;
666+
667+ test ( "deleteForgeRelease github DELETEs by id" , async ( ) => {
668+ const mock = vi . fn ( ( ) => Promise . resolve ( new Response ( null , { status : 204 } ) ) ) ;
669+ stubGlobal ( "fetch" , mock ) ;
670+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
671+ await deleteForgeRelease ( info , 4242 , [ "gh-tok" ] ) ;
672+ expect ( mock ) . toHaveBeenCalledOnce ( ) ;
673+ const [ url , init ] = mock . mock . calls [ 0 ] as unknown as [ string , RequestInit ] ;
674+ expect ( url ) . toEqual ( "https://api.github.com/repos/o/r/releases/4242" ) ;
675+ expect ( init . method ) . toEqual ( "DELETE" ) ;
676+ expect ( authOf ( init ) ) . toEqual ( "Bearer gh-tok" ) ;
677+ } ) ;
678+
679+ test ( "deleteForgeRelease gitea DELETEs by id" , async ( ) => {
680+ const mock = vi . fn ( ( ) => Promise . resolve ( new Response ( null , { status : 204 } ) ) ) ;
681+ stubGlobal ( "fetch" , mock ) ;
682+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "gitea.example.com" , type : "gitea" } ;
683+ await deleteForgeRelease ( info , 35141 , [ "gitea-tok" ] ) ;
684+ const [ url , init ] = mock . mock . calls [ 0 ] as unknown as [ string , RequestInit ] ;
685+ expect ( url ) . toEqual ( "https://gitea.example.com/api/v1/repos/o/r/releases/35141" ) ;
686+ expect ( authOf ( init ) ) . toEqual ( "token gitea-tok" ) ;
687+ } ) ;
688+
689+ test ( "deleteForgeRelease tolerates 404" , async ( ) => {
690+ stubGlobal ( "fetch" , vi . fn ( ( ) => Promise . resolve ( new Response ( "not found" , { status : 404 } ) ) ) ) ;
691+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
692+ await deleteForgeRelease ( info , 1 , [ "tok" ] ) ;
693+ } ) ;
694+
695+ test ( "deleteForgeRelease falls back across tokens on 401" , async ( ) => {
696+ const mock = vi . fn ( )
697+ . mockResolvedValueOnce ( new Response ( "auth" , { status : 401 , statusText : "Unauthorized" } ) )
698+ . mockResolvedValueOnce ( new Response ( null , { status : 204 } ) ) ;
699+ stubGlobal ( "fetch" , mock ) ;
700+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
701+ await deleteForgeRelease ( info , 7 , [ "bad" , "good" ] ) ;
702+ expect ( mock ) . toHaveBeenCalledTimes ( 2 ) ;
703+ } ) ;
704+
705+ test ( "deleteForgeRelease throws on non-auth error" , async ( ) => {
706+ stubGlobal ( "fetch" , vi . fn ( ( ) => Promise . resolve ( new Response ( "server" , { status : 500 , statusText : "Internal Server Error" } ) ) ) ) ;
707+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
708+ await expect ( deleteForgeRelease ( info , 1 , [ "tok" ] ) ) . rejects . toThrow ( "500" ) ;
709+ } ) ;
710+
711+ test ( "deleteForgeRelease throws when all tokens fail" , async ( ) => {
712+ stubGlobal ( "fetch" , vi . fn ( ( ) => Promise . resolve ( new Response ( "auth" , { status : 401 , statusText : "Unauthorized" } ) ) ) ) ;
713+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "github.com" , type : "github" } ;
714+ await expect ( deleteForgeRelease ( info , 1 , [ "a" , "b" ] ) ) . rejects . toThrow ( "401" ) ;
715+ } ) ;
716+
717+ test ( "deleteForgeRelease network error includes cause" , async ( ) => {
718+ stubGlobal ( "fetch" , vi . fn ( ) . mockRejectedValue (
719+ Object . assign ( new TypeError ( "fetch failed" ) , { cause : new Error ( "getaddrinfo ENOTFOUND example.com" ) } ) ,
720+ ) ) ;
721+ const info : RepoInfo = { owner : "o" , repo : "r" , host : "example.com" , type : "gitea" } ;
722+ await expect ( deleteForgeRelease ( info , 1 , [ "tok" ] ) ) . rejects . toThrow ( "getaddrinfo ENOTFOUND example.com" ) ;
723+ } ) ;
724+
515725test ( "release rejects detached HEAD" , ( ) => withTmpDir ( async ( tmpDir ) => {
516726 await writeFile ( join ( tmpDir , "package.json" ) , JSON . stringify ( { name : "test-pkg" , version : "1.0.0" } , null , 2 ) ) ;
517727
@@ -930,7 +1140,7 @@ test("--remote with --release uses that remote for forge detection", () => withT
9301140 }
9311141 expect ( err ) . toBeInstanceOf ( SubprocessError ) ;
9321142 expect ( err . exitCode ) . toEqual ( 1 ) ;
933- expect ( err . output ) . toContain ( " Failed to create release" ) ;
1143+ expect ( err . output ) . toMatch ( / F a i l e d t o ( c r e a t e | l i s t ) r e l e a s e / ) ;
9341144 expect ( err . output ) . not . toContain ( "Could not determine repository type" ) ;
9351145} ) ) ;
9361146
0 commit comments