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

Skip to content

Commit ab416a1

Browse files
authored
fix: canonicalize releases after concurrent create (#746)
Signed-off-by: Rui Chen <[email protected]>
1 parent 71d29a0 commit ab416a1

3 files changed

Lines changed: 280 additions & 20 deletions

File tree

__tests__/github.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ describe('github', () => {
7777
},
7878
listReleaseAssets: () => Promise.reject('Not implemented'),
7979
deleteReleaseAsset: () => Promise.reject('Not implemented'),
80+
deleteRelease: () => Promise.reject('Not implemented'),
8081
uploadReleaseAsset: () => Promise.reject('Not implemented'),
8182
} as const;
8283

@@ -185,6 +186,7 @@ describe('github', () => {
185186
},
186187
listReleaseAssets: () => Promise.reject('Not implemented'),
187188
deleteReleaseAsset: () => Promise.reject('Not implemented'),
189+
deleteRelease: () => Promise.reject('Not implemented'),
188190
uploadReleaseAsset: () => Promise.reject('Not implemented'),
189191
};
190192

@@ -262,12 +264,122 @@ describe('github', () => {
262264
},
263265
listReleaseAssets: () => Promise.reject('Not implemented'),
264266
deleteReleaseAsset: () => Promise.reject('Not implemented'),
267+
deleteRelease: () => Promise.reject('Not implemented'),
265268
uploadReleaseAsset: () => Promise.reject('Not implemented'),
266269
} as const;
267270

268271
const result = await release(config, mockReleaser, 2);
269272
assert.ok(result);
270273
assert.equal(result.id, 1);
271274
});
275+
276+
it('reuses a canonical release after concurrent create success and removes empty duplicates', async () => {
277+
const canonicalRelease: Release = {
278+
id: 1,
279+
upload_url: 'canonical-upload',
280+
html_url: 'canonical-html',
281+
tag_name: 'v1.0.0',
282+
name: 'canonical',
283+
body: 'test',
284+
target_commitish: 'main',
285+
draft: true,
286+
prerelease: false,
287+
assets: [],
288+
};
289+
const duplicateRelease: Release = {
290+
id: 2,
291+
upload_url: 'duplicate-upload',
292+
html_url: 'duplicate-html',
293+
tag_name: 'v1.0.0',
294+
name: 'duplicate',
295+
body: 'test',
296+
target_commitish: 'main',
297+
draft: true,
298+
prerelease: false,
299+
assets: [],
300+
};
301+
302+
let lookupCount = 0;
303+
const deleteReleaseSpy = vi.fn(async () => undefined);
304+
const mockReleaser: Releaser = {
305+
getReleaseByTag: () => {
306+
lookupCount += 1;
307+
if (lookupCount === 1) {
308+
return Promise.reject({ status: 404 });
309+
}
310+
return Promise.resolve({ data: canonicalRelease });
311+
},
312+
createRelease: () => Promise.resolve({ data: duplicateRelease }),
313+
updateRelease: () => Promise.reject('Not implemented'),
314+
finalizeRelease: () => Promise.reject('Not implemented'),
315+
allReleases: async function* () {
316+
yield { data: [duplicateRelease, canonicalRelease] };
317+
},
318+
listReleaseAssets: () => Promise.reject('Not implemented'),
319+
deleteReleaseAsset: () => Promise.reject('Not implemented'),
320+
deleteRelease: deleteReleaseSpy,
321+
uploadReleaseAsset: () => Promise.reject('Not implemented'),
322+
};
323+
324+
const result = await release(config, mockReleaser, 2);
325+
326+
assert.equal(result.id, canonicalRelease.id);
327+
expect(deleteReleaseSpy).toHaveBeenCalledWith({
328+
owner: 'owner',
329+
repo: 'repo',
330+
release_id: duplicateRelease.id,
331+
});
332+
});
333+
334+
it('falls back to recent releases when tag lookup still lags after create', async () => {
335+
const canonicalRelease: Release = {
336+
id: 1,
337+
upload_url: 'canonical-upload',
338+
html_url: 'canonical-html',
339+
tag_name: 'v1.0.0',
340+
name: 'canonical',
341+
body: 'test',
342+
target_commitish: 'main',
343+
draft: true,
344+
prerelease: false,
345+
assets: [],
346+
};
347+
const duplicateRelease: Release = {
348+
id: 2,
349+
upload_url: 'duplicate-upload',
350+
html_url: 'duplicate-html',
351+
tag_name: 'v1.0.0',
352+
name: 'duplicate',
353+
body: 'test',
354+
target_commitish: 'main',
355+
draft: true,
356+
prerelease: false,
357+
assets: [],
358+
};
359+
360+
const deleteReleaseSpy = vi.fn(async () => undefined);
361+
const mockReleaser: Releaser = {
362+
getReleaseByTag: () => Promise.reject({ status: 404 }),
363+
createRelease: () => Promise.resolve({ data: duplicateRelease }),
364+
updateRelease: () => Promise.reject('Not implemented'),
365+
finalizeRelease: () => Promise.reject('Not implemented'),
366+
allReleases: async function* () {
367+
yield { data: [duplicateRelease, canonicalRelease] };
368+
},
369+
listReleaseAssets: () => Promise.reject('Not implemented'),
370+
deleteReleaseAsset: () => Promise.reject('Not implemented'),
371+
deleteRelease: deleteReleaseSpy,
372+
uploadReleaseAsset: () => Promise.reject('Not implemented'),
373+
};
374+
375+
const result = await release(config, mockReleaser, 1);
376+
377+
assert.equal(result.id, canonicalRelease.id);
378+
expect(deleteReleaseSpy).toHaveBeenCalledWith({
379+
owner: 'owner',
380+
repo: 'repo',
381+
release_id: duplicateRelease.id,
382+
});
383+
});
272384
});
273385
});

dist/index.js

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

src/github.ts

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export interface Releaser {
7575

7676
deleteReleaseAsset(params: { owner: string; repo: string; asset_id: number }): Promise<void>;
7777

78+
deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void>;
79+
7880
uploadReleaseAsset(params: {
7981
url: string;
8082
size: number;
@@ -224,6 +226,10 @@ export class GitHubReleaser implements Releaser {
224226
await this.github.rest.repos.deleteReleaseAsset(params);
225227
}
226228

229+
async deleteRelease(params: { owner: string; repo: string; release_id: number }): Promise<void> {
230+
await this.github.rest.repos.deleteRelease(params);
231+
}
232+
227233
async uploadReleaseAsset(params: {
228234
url: string;
229235
size: number;
@@ -525,6 +531,141 @@ export async function findTagFromReleases(
525531
}
526532
}
527533

534+
const CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS = 1000;
535+
const RECENT_RELEASE_SCAN_PAGES = 2;
536+
537+
async function sleep(ms: number): Promise<void> {
538+
await new Promise((resolve) => setTimeout(resolve, ms));
539+
}
540+
541+
async function recentReleasesByTag(
542+
releaser: Releaser,
543+
owner: string,
544+
repo: string,
545+
tag: string,
546+
): Promise<Release[]> {
547+
const matches: Release[] = [];
548+
let pages = 0;
549+
550+
for await (const page of releaser.allReleases({ owner, repo })) {
551+
matches.push(...page.data.filter((release) => release.tag_name === tag));
552+
pages += 1;
553+
554+
if (pages >= RECENT_RELEASE_SCAN_PAGES) {
555+
break;
556+
}
557+
}
558+
559+
return matches;
560+
}
561+
562+
function pickCanonicalRelease(
563+
releases: Release[],
564+
releaseByTag: Release | undefined,
565+
): Release | undefined {
566+
if (releaseByTag && releases.some((release) => release.id === releaseByTag.id)) {
567+
return releaseByTag;
568+
}
569+
570+
if (releases.length === 0) {
571+
return releaseByTag;
572+
}
573+
574+
return [...releases].sort((left, right) => {
575+
if (left.draft !== right.draft) {
576+
return Number(left.draft) - Number(right.draft);
577+
}
578+
579+
return left.id - right.id;
580+
})[0];
581+
}
582+
583+
async function cleanupDuplicateDraftReleases(
584+
releaser: Releaser,
585+
owner: string,
586+
repo: string,
587+
tag: string,
588+
canonicalReleaseId: number,
589+
recentReleases: Release[],
590+
): Promise<void> {
591+
for (const duplicate of recentReleases) {
592+
if (duplicate.id === canonicalReleaseId || !duplicate.draft || duplicate.assets.length > 0) {
593+
continue;
594+
}
595+
596+
try {
597+
console.log(`🧹 Removing duplicate draft release ${duplicate.id} for tag ${tag}...`);
598+
await releaser.deleteRelease({
599+
owner,
600+
repo,
601+
release_id: duplicate.id,
602+
});
603+
} catch (error) {
604+
console.warn(`error deleting duplicate release ${duplicate.id}: ${error}`);
605+
}
606+
}
607+
}
608+
609+
async function canonicalizeCreatedRelease(
610+
releaser: Releaser,
611+
owner: string,
612+
repo: string,
613+
tag: string,
614+
createdRelease: Release,
615+
maxRetries: number,
616+
): Promise<Release> {
617+
const attempts = Math.max(maxRetries, 1);
618+
619+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
620+
let releaseByTag: Release | undefined;
621+
try {
622+
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag);
623+
} catch (error) {
624+
console.warn(`error reloading release for tag ${tag}: ${error}`);
625+
}
626+
627+
let recentReleases: Release[] = [];
628+
try {
629+
recentReleases = await recentReleasesByTag(releaser, owner, repo, tag);
630+
} catch (error) {
631+
console.warn(`error listing recent releases for tag ${tag}: ${error}`);
632+
}
633+
634+
const canonicalRelease = pickCanonicalRelease(recentReleases, releaseByTag);
635+
if (canonicalRelease) {
636+
if (canonicalRelease.id !== createdRelease.id) {
637+
console.log(
638+
`↪️ Using release ${canonicalRelease.id} for tag ${tag} instead of duplicate draft ${createdRelease.id}`,
639+
);
640+
}
641+
642+
await cleanupDuplicateDraftReleases(
643+
releaser,
644+
owner,
645+
repo,
646+
tag,
647+
canonicalRelease.id,
648+
recentReleases,
649+
);
650+
return canonicalRelease;
651+
}
652+
653+
if (attempt < attempts) {
654+
console.log(
655+
`Release ${createdRelease.id} is not yet discoverable by tag ${tag}, retrying... (${
656+
attempts - attempt
657+
} retries remaining)`,
658+
);
659+
await sleep(CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS);
660+
}
661+
}
662+
663+
console.log(
664+
`⚠️ Continuing with newly created release ${createdRelease.id} because tag ${tag} is still not discoverable`,
665+
);
666+
return createdRelease;
667+
}
668+
528669
async function createRelease(
529670
tag: string,
530671
config: Config,
@@ -547,7 +688,7 @@ async function createRelease(
547688
}
548689
console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
549690
try {
550-
let release = await releaser.createRelease({
691+
const release = await releaser.createRelease({
551692
owner,
552693
repo,
553694
tag_name,
@@ -560,7 +701,14 @@ async function createRelease(
560701
generate_release_notes,
561702
make_latest,
562703
});
563-
return release.data;
704+
return await canonicalizeCreatedRelease(
705+
releaser,
706+
owner,
707+
repo,
708+
tag_name,
709+
release.data,
710+
maxRetries,
711+
);
564712
} catch (error) {
565713
// presume a race with competing matrix runs
566714
console.log(`⚠️ GitHub release failed with status: ${error.status}`);

0 commit comments

Comments
 (0)