@@ -614,6 +614,78 @@ describe('github', () => {
614614 expect ( uploadReleaseAsset ) . toHaveBeenCalledTimes ( 2 ) ;
615615 } ) ;
616616
617+ it ( 'retries upload after deleting a conflicting renamed asset matched by label' , async ( ) => {
618+ const tempDir = mkdtempSync ( join ( tmpdir ( ) , 'gh-release-race-dotfile-' ) ) ;
619+ const dotfilePath = join ( tempDir , '.config' ) ;
620+ writeFileSync ( dotfilePath , 'config' ) ;
621+
622+ const uploadReleaseAsset = vi
623+ . fn ( )
624+ . mockRejectedValueOnce ( {
625+ status : 422 ,
626+ response : { data : { errors : [ { code : 'already_exists' } ] } } ,
627+ } )
628+ . mockResolvedValueOnce ( {
629+ status : 201 ,
630+ data : { id : 123 , name : 'default.config' , label : '.config' } ,
631+ } ) ;
632+
633+ const listReleaseAssets = vi
634+ . fn ( )
635+ . mockResolvedValue ( [ { id : 99 , name : 'default.config' , label : '.config' } ] ) ;
636+ const deleteReleaseAsset = vi . fn ( ) . mockResolvedValue ( undefined ) ;
637+ const updateReleaseAsset = vi . fn ( ) . mockResolvedValue ( {
638+ data : { id : 123 , name : 'default.config' , label : '.config' } ,
639+ } ) ;
640+
641+ const mockReleaser : Releaser = {
642+ getReleaseByTag : ( ) => Promise . reject ( 'Not implemented' ) ,
643+ createRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
644+ updateRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
645+ finalizeRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
646+ allReleases : async function * ( ) {
647+ throw new Error ( 'Not implemented' ) ;
648+ } ,
649+ listReleaseAssets,
650+ deleteReleaseAsset,
651+ deleteRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
652+ updateReleaseAsset,
653+ uploadReleaseAsset,
654+ } ;
655+
656+ try {
657+ const result = await upload (
658+ config ,
659+ mockReleaser ,
660+ 'https://uploads.github.com/repos/owner/repo/releases/1/assets' ,
661+ dotfilePath ,
662+ [ ] ,
663+ ) ;
664+
665+ expect ( result ) . toStrictEqual ( { id : 123 , name : 'default.config' , label : '.config' } ) ;
666+ expect ( listReleaseAssets ) . toHaveBeenCalledWith ( {
667+ owner : 'owner' ,
668+ repo : 'repo' ,
669+ release_id : 1 ,
670+ } ) ;
671+ expect ( deleteReleaseAsset ) . toHaveBeenCalledWith ( {
672+ owner : 'owner' ,
673+ repo : 'repo' ,
674+ asset_id : 99 ,
675+ } ) ;
676+ expect ( updateReleaseAsset ) . toHaveBeenCalledWith ( {
677+ owner : 'owner' ,
678+ repo : 'repo' ,
679+ asset_id : 123 ,
680+ name : 'default.config' ,
681+ label : '.config' ,
682+ } ) ;
683+ expect ( uploadReleaseAsset ) . toHaveBeenCalledTimes ( 2 ) ;
684+ } finally {
685+ rmSync ( tempDir , { recursive : true , force : true } ) ;
686+ }
687+ } ) ;
688+
617689 it ( 'handles 422 already_exists error gracefully' , async ( ) => {
618690 const existingRelease = {
619691 id : 1 ,
@@ -963,6 +1035,263 @@ describe('github', () => {
9631035 }
9641036 } ) ;
9651037
1038+ it ( 'refreshes release assets when the uploaded renamed asset is not immediately patchable' , async ( ) => {
1039+ const tempDir = mkdtempSync ( join ( tmpdir ( ) , 'gh-release-dotfile-' ) ) ;
1040+ const dotfilePath = join ( tempDir , '.config' ) ;
1041+ writeFileSync ( dotfilePath , 'config' ) ;
1042+
1043+ const updateReleaseAssetSpy = vi
1044+ . fn ( )
1045+ . mockRejectedValueOnce ( { status : 404 } )
1046+ . mockResolvedValueOnce ( {
1047+ data : {
1048+ id : 2 ,
1049+ name : 'default.config' ,
1050+ label : '.config' ,
1051+ } ,
1052+ } ) ;
1053+ const listReleaseAssetsSpy = vi . fn ( ) . mockResolvedValue ( [
1054+ {
1055+ id : 2 ,
1056+ name : 'default.config' ,
1057+ label : '' ,
1058+ } ,
1059+ ] ) ;
1060+ const releaser : Releaser = {
1061+ getReleaseByTag : ( ) => Promise . reject ( 'Not implemented' ) ,
1062+ createRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1063+ updateRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1064+ finalizeRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1065+ allReleases : async function * ( ) {
1066+ throw new Error ( 'Not implemented' ) ;
1067+ } ,
1068+ listReleaseAssets : listReleaseAssetsSpy ,
1069+ deleteReleaseAsset : ( ) => Promise . reject ( 'Not implemented' ) ,
1070+ deleteRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1071+ updateReleaseAsset : updateReleaseAssetSpy ,
1072+ uploadReleaseAsset : ( ) =>
1073+ Promise . resolve ( {
1074+ status : 201 ,
1075+ data : {
1076+ id : 1 ,
1077+ name : 'default.config' ,
1078+ label : '' ,
1079+ } ,
1080+ } ) ,
1081+ } ;
1082+
1083+ try {
1084+ const result = await upload (
1085+ config ,
1086+ releaser ,
1087+ 'https://uploads.github.com/repos/owner/repo/releases/1/assets' ,
1088+ dotfilePath ,
1089+ [ ] ,
1090+ ) ;
1091+
1092+ expect ( updateReleaseAssetSpy ) . toHaveBeenNthCalledWith ( 1 , {
1093+ owner : 'owner' ,
1094+ repo : 'repo' ,
1095+ asset_id : 1 ,
1096+ name : 'default.config' ,
1097+ label : '.config' ,
1098+ } ) ;
1099+ expect ( listReleaseAssetsSpy ) . toHaveBeenCalledWith ( {
1100+ owner : 'owner' ,
1101+ repo : 'repo' ,
1102+ release_id : 1 ,
1103+ } ) ;
1104+ expect ( updateReleaseAssetSpy ) . toHaveBeenNthCalledWith ( 2 , {
1105+ owner : 'owner' ,
1106+ repo : 'repo' ,
1107+ asset_id : 2 ,
1108+ name : 'default.config' ,
1109+ label : '.config' ,
1110+ } ) ;
1111+ expect ( result ) . toEqual ( {
1112+ id : 2 ,
1113+ name : 'default.config' ,
1114+ label : '.config' ,
1115+ } ) ;
1116+ } finally {
1117+ rmSync ( tempDir , { recursive : true , force : true } ) ;
1118+ }
1119+ } ) ;
1120+
1121+ it ( 'treats update-a-release-asset 404 as success when a matching asset is present after refresh' , async ( ) => {
1122+ const tempDir = mkdtempSync ( join ( tmpdir ( ) , 'gh-release-dotfile-' ) ) ;
1123+ const dotfilePath = join ( tempDir , '.config' ) ;
1124+ writeFileSync ( dotfilePath , 'config' ) ;
1125+
1126+ const listReleaseAssetsSpy = vi . fn ( ) . mockResolvedValue ( [
1127+ {
1128+ id : 2 ,
1129+ name : 'default.config' ,
1130+ label : '.config' ,
1131+ } ,
1132+ ] ) ;
1133+ const releaser : Releaser = {
1134+ getReleaseByTag : ( ) => Promise . reject ( 'Not implemented' ) ,
1135+ createRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1136+ updateRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1137+ finalizeRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1138+ allReleases : async function * ( ) {
1139+ throw new Error ( 'Not implemented' ) ;
1140+ } ,
1141+ listReleaseAssets : listReleaseAssetsSpy ,
1142+ deleteReleaseAsset : ( ) => Promise . reject ( 'Not implemented' ) ,
1143+ deleteRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1144+ updateReleaseAsset : ( ) => Promise . reject ( 'Not implemented' ) ,
1145+ uploadReleaseAsset : ( ) =>
1146+ Promise . reject ( {
1147+ status : 404 ,
1148+ message :
1149+ 'Not Found - https://docs.github.com/rest/releases/assets#update-a-release-asset' ,
1150+ } ) ,
1151+ } ;
1152+
1153+ try {
1154+ const result = await upload (
1155+ config ,
1156+ releaser ,
1157+ 'https://uploads.github.com/repos/owner/repo/releases/1/assets' ,
1158+ dotfilePath ,
1159+ [ ] ,
1160+ ) ;
1161+
1162+ expect ( listReleaseAssetsSpy ) . toHaveBeenCalledWith ( {
1163+ owner : 'owner' ,
1164+ repo : 'repo' ,
1165+ release_id : 1 ,
1166+ } ) ;
1167+ expect ( result ) . toEqual ( {
1168+ id : 2 ,
1169+ name : 'default.config' ,
1170+ label : '.config' ,
1171+ } ) ;
1172+ } finally {
1173+ rmSync ( tempDir , { recursive : true , force : true } ) ;
1174+ }
1175+ } ) ;
1176+
1177+ it ( 'treats upload-endpoint 404s as release asset metadata failures when the docs link matches' , async ( ) => {
1178+ const tempDir = mkdtempSync ( join ( tmpdir ( ) , 'gh-release-dotfile-' ) ) ;
1179+ const dotfilePath = join ( tempDir , '.config' ) ;
1180+ writeFileSync ( dotfilePath , 'config' ) ;
1181+
1182+ const listReleaseAssetsSpy = vi . fn ( ) . mockResolvedValue ( [
1183+ {
1184+ id : 2 ,
1185+ name : 'default.config' ,
1186+ label : '.config' ,
1187+ } ,
1188+ ] ) ;
1189+ const releaser : Releaser = {
1190+ getReleaseByTag : ( ) => Promise . reject ( 'Not implemented' ) ,
1191+ createRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1192+ updateRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1193+ finalizeRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1194+ allReleases : async function * ( ) {
1195+ throw new Error ( 'Not implemented' ) ;
1196+ } ,
1197+ listReleaseAssets : listReleaseAssetsSpy ,
1198+ deleteReleaseAsset : ( ) => Promise . reject ( 'Not implemented' ) ,
1199+ deleteRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1200+ updateReleaseAsset : ( ) => Promise . reject ( 'Not implemented' ) ,
1201+ uploadReleaseAsset : ( ) =>
1202+ Promise . reject ( {
1203+ status : 404 ,
1204+ message :
1205+ 'Not Found - https://docs.github.com/rest/releases/assets#update-a-release-asset' ,
1206+ request : {
1207+ url : 'https://uploads.github.com/repos/owner/repo/releases/1/assets?name=.config' ,
1208+ } ,
1209+ } ) ,
1210+ } ;
1211+
1212+ try {
1213+ const result = await upload (
1214+ config ,
1215+ releaser ,
1216+ 'https://uploads.github.com/repos/owner/repo/releases/1/assets' ,
1217+ dotfilePath ,
1218+ [ ] ,
1219+ ) ;
1220+
1221+ expect ( listReleaseAssetsSpy ) . toHaveBeenCalledWith ( {
1222+ owner : 'owner' ,
1223+ repo : 'repo' ,
1224+ release_id : 1 ,
1225+ } ) ;
1226+ expect ( result ) . toEqual ( {
1227+ id : 2 ,
1228+ name : 'default.config' ,
1229+ label : '.config' ,
1230+ } ) ;
1231+ } finally {
1232+ rmSync ( tempDir , { recursive : true , force : true } ) ;
1233+ }
1234+ } ) ;
1235+
1236+ it ( 'polls for a matching asset after update-a-release-asset 404 before failing' , async ( ) => {
1237+ const tempDir = mkdtempSync ( join ( tmpdir ( ) , 'gh-release-dotfile-' ) ) ;
1238+ const dotfilePath = join ( tempDir , '.config' ) ;
1239+ writeFileSync ( dotfilePath , 'config' ) ;
1240+
1241+ const listReleaseAssetsSpy = vi
1242+ . fn ( )
1243+ . mockResolvedValueOnce ( [ ] )
1244+ . mockResolvedValueOnce ( [
1245+ {
1246+ id : 2 ,
1247+ name : 'default.config' ,
1248+ label : '.config' ,
1249+ } ,
1250+ ] ) ;
1251+ const releaser : Releaser = {
1252+ getReleaseByTag : ( ) => Promise . reject ( 'Not implemented' ) ,
1253+ createRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1254+ updateRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1255+ finalizeRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1256+ allReleases : async function * ( ) {
1257+ throw new Error ( 'Not implemented' ) ;
1258+ } ,
1259+ listReleaseAssets : listReleaseAssetsSpy ,
1260+ deleteReleaseAsset : ( ) => Promise . reject ( 'Not implemented' ) ,
1261+ deleteRelease : ( ) => Promise . reject ( 'Not implemented' ) ,
1262+ updateReleaseAsset : ( ) => Promise . reject ( 'Not implemented' ) ,
1263+ uploadReleaseAsset : ( ) =>
1264+ Promise . reject ( {
1265+ status : 404 ,
1266+ message :
1267+ 'Not Found - https://docs.github.com/rest/releases/assets#update-a-release-asset' ,
1268+ } ) ,
1269+ } ;
1270+
1271+ try {
1272+ const resultPromise = upload (
1273+ config ,
1274+ releaser ,
1275+ 'https://uploads.github.com/repos/owner/repo/releases/1/assets' ,
1276+ dotfilePath ,
1277+ [ ] ,
1278+ ) ;
1279+
1280+ await new Promise ( ( resolve ) => setTimeout ( resolve , 1100 ) ) ;
1281+
1282+ const result = await resultPromise ;
1283+
1284+ expect ( listReleaseAssetsSpy ) . toHaveBeenCalledTimes ( 2 ) ;
1285+ expect ( result ) . toEqual ( {
1286+ id : 2 ,
1287+ name : 'default.config' ,
1288+ label : '.config' ,
1289+ } ) ;
1290+ } finally {
1291+ rmSync ( tempDir , { recursive : true , force : true } ) ;
1292+ }
1293+ } ) ;
1294+
9661295 it ( 'matches an existing asset by label when overwriting a dotfile' , async ( ) => {
9671296 const tempDir = mkdtempSync ( join ( tmpdir ( ) , 'gh-release-dotfile-' ) ) ;
9681297 const dotfilePath = join ( tempDir , '.config' ) ;
0 commit comments