mirror of
https://github.com/softprops/action-gh-release.git
synced 2026-03-15 09:20:54 -04:00
fix: handle upload already_exists races across workflows (#745)
* Handle upload already_exists races across workflows * fix: rebase duplicate asset race handling Signed-off-by: Rui Chen <rui@chenrui.dev> --------- Signed-off-by: Rui Chen <rui@chenrui.dev> Co-authored-by: Aditya Inamdar <api2062@Adityas-MacBook-Air.local> Co-authored-by: Rui Chen <rui@chenrui.dev>
This commit is contained in:
@@ -279,6 +279,56 @@ describe('github', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries upload after deleting conflicting asset on 422 already_exists race', async () => {
|
||||||
|
const uploadReleaseAsset = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce({
|
||||||
|
status: 422,
|
||||||
|
response: { data: { errors: [{ code: 'already_exists' }] } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 201,
|
||||||
|
data: { id: 123, name: 'release.txt' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const listReleaseAssets = vi.fn().mockResolvedValue([{ id: 99, name: 'release.txt' }]);
|
||||||
|
const deleteReleaseAsset = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockReleaser: Releaser = {
|
||||||
|
getReleaseByTag: () => Promise.reject('Not implemented'),
|
||||||
|
createRelease: () => Promise.reject('Not implemented'),
|
||||||
|
updateRelease: () => Promise.reject('Not implemented'),
|
||||||
|
finalizeRelease: () => Promise.reject('Not implemented'),
|
||||||
|
allReleases: async function* () {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
},
|
||||||
|
listReleaseAssets,
|
||||||
|
deleteReleaseAsset,
|
||||||
|
uploadReleaseAsset,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await upload(
|
||||||
|
config,
|
||||||
|
mockReleaser,
|
||||||
|
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
|
||||||
|
'__tests__/release.txt',
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({ id: 123, name: 'release.txt' });
|
||||||
|
expect(listReleaseAssets).toHaveBeenCalledWith({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
release_id: 1,
|
||||||
|
});
|
||||||
|
expect(deleteReleaseAsset).toHaveBeenCalledWith({
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
asset_id: 99,
|
||||||
|
});
|
||||||
|
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('handles 422 already_exists error gracefully', async () => {
|
it('handles 422 already_exists error gracefully', async () => {
|
||||||
const existingRelease = {
|
const existingRelease = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
60
dist/index.js
vendored
60
dist/index.js
vendored
File diff suppressed because one or more lines are too long
@@ -289,6 +289,8 @@ export const upload = async (
|
|||||||
): Promise<any> => {
|
): Promise<any> => {
|
||||||
const [owner, repo] = config.github_repository.split('/');
|
const [owner, repo] = config.github_repository.split('/');
|
||||||
const { name, mime, size } = asset(path);
|
const { name, mime, size } = asset(path);
|
||||||
|
const releaseIdMatch = url.match(/\/releases\/(\d+)\/assets/);
|
||||||
|
const releaseId = releaseIdMatch ? Number(releaseIdMatch[1]) : undefined;
|
||||||
const currentAsset = currentAssets.find(
|
const currentAsset = currentAssets.find(
|
||||||
// note: GitHub renames asset filenames that have special characters, non-alphanumeric characters, and leading or trailing periods. The "List release assets" endpoint lists the renamed filenames.
|
// note: GitHub renames asset filenames that have special characters, non-alphanumeric characters, and leading or trailing periods. The "List release assets" endpoint lists the renamed filenames.
|
||||||
// due to this renaming we need to be mindful when we compare the file name we're uploading with a name github may already have rewritten for logical comparison
|
// due to this renaming we need to be mindful when we compare the file name we're uploading with a name github may already have rewritten for logical comparison
|
||||||
@@ -312,15 +314,23 @@ export const upload = async (
|
|||||||
console.log(`⬆️ Uploading ${name}...`);
|
console.log(`⬆️ Uploading ${name}...`);
|
||||||
const endpoint = new URL(url);
|
const endpoint = new URL(url);
|
||||||
endpoint.searchParams.append('name', name);
|
endpoint.searchParams.append('name', name);
|
||||||
const fh = await open(path);
|
const uploadAsset = async () => {
|
||||||
|
const fh = await open(path);
|
||||||
|
try {
|
||||||
|
return await releaser.uploadReleaseAsset({
|
||||||
|
url: endpoint.toString(),
|
||||||
|
size,
|
||||||
|
mime,
|
||||||
|
token: config.github_token,
|
||||||
|
data: fh.readableWebStream({ type: 'bytes' }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fh.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await releaser.uploadReleaseAsset({
|
const resp = await uploadAsset();
|
||||||
url: endpoint.toString(),
|
|
||||||
size,
|
|
||||||
mime,
|
|
||||||
token: config.github_token,
|
|
||||||
data: fh.readableWebStream({ type: 'bytes' }),
|
|
||||||
});
|
|
||||||
const json = resp.data;
|
const json = resp.data;
|
||||||
if (resp.status !== 201) {
|
if (resp.status !== 201) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -347,8 +357,49 @@ export const upload = async (
|
|||||||
}
|
}
|
||||||
console.log(`✅ Uploaded ${name}`);
|
console.log(`✅ Uploaded ${name}`);
|
||||||
return json;
|
return json;
|
||||||
} finally {
|
} catch (error: any) {
|
||||||
await fh.close();
|
const errorStatus = error?.status ?? error?.response?.status;
|
||||||
|
const errorData = error?.response?.data;
|
||||||
|
|
||||||
|
// Handle race conditions across concurrent workflows uploading the same asset.
|
||||||
|
if (
|
||||||
|
config.input_overwrite_files !== false &&
|
||||||
|
errorStatus === 422 &&
|
||||||
|
errorData?.errors?.[0]?.code === 'already_exists' &&
|
||||||
|
releaseId !== undefined
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ Asset ${name} already exists (race condition), refreshing assets and retrying once...`,
|
||||||
|
);
|
||||||
|
const latestAssets = await releaser.listReleaseAssets({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
release_id: releaseId,
|
||||||
|
});
|
||||||
|
const latestAsset = latestAssets.find(
|
||||||
|
({ name: currentName }) => currentName == alignAssetName(name),
|
||||||
|
);
|
||||||
|
if (latestAsset) {
|
||||||
|
await releaser.deleteReleaseAsset({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
asset_id: latestAsset.id,
|
||||||
|
});
|
||||||
|
const retryResp = await uploadAsset();
|
||||||
|
const retryJson = retryResp.data;
|
||||||
|
if (retryResp.status !== 201) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to upload release asset ${name}. received status code ${
|
||||||
|
retryResp.status
|
||||||
|
}\n${retryJson.message}\n${JSON.stringify(retryJson.errors)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`✅ Uploaded ${name}`);
|
||||||
|
return retryJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user