Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 6ca3b5d

Browse files
authored
fix: recover concurrent asset metadata 404s (#760)
Signed-off-by: Rui Chen <[email protected]>
1 parent 11f9176 commit 6ca3b5d

3 files changed

Lines changed: 478 additions & 64 deletions

File tree

‎__tests__/github.test.ts‎

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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');

‎dist/index.js‎

Lines changed: 25 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)