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:
api2062
2026-03-14 18:31:14 -07:00
committed by GitHub
parent 4aadb0df8b
commit 52847653ee
3 changed files with 142 additions and 39 deletions

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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;
} }
}; };