fix: publish existing draft releases as prereleases (#801)

* fix: respect input_draft when updating existing release

* fix: reuse draft releases before publishing

Signed-off-by: Rui Chen <rui@chenrui.dev>

* fix: bound and canonicalize draft discovery

Signed-off-by: Rui Chen <rui@chenrui.dev>

* fix: preserve unowned draft releases

Signed-off-by: Rui Chen <rui@chenrui.dev>

* fix: recheck duplicate drafts before cleanup

Signed-off-by: Rui Chen <rui@chenrui.dev>

---------

Signed-off-by: Rui Chen <rui@chenrui.dev>
Co-authored-by: Rui Chen <rui@chenrui.dev>
This commit is contained in:
fenley
2026-07-13 13:59:29 +08:00
committed by GitHub
parent d8a89a2066
commit f345337888
5 changed files with 427 additions and 83 deletions
+81 -47
View File
@@ -531,23 +531,31 @@ export const release = async (
if (generate_release_notes && previous_tag_name) {
console.log(`📝 Generating release notes using previous tag ${previous_tag_name}`);
}
let _release: Release | undefined;
try {
const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag);
_release = await findTagFromReleases(releaser, owner, repo, tag, maxRetries);
} catch (error) {
console.log(
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`,
);
throw error;
}
if (_release === undefined) {
return await createRelease(
tag,
config,
releaser,
owner,
repo,
discussion_category_name,
generate_release_notes,
maxRetries,
previous_tag_name,
);
}
if (_release === undefined) {
return await createRelease(
tag,
config,
releaser,
owner,
repo,
discussion_category_name,
generate_release_notes,
maxRetries,
previous_tag_name,
);
}
try {
let existingRelease: Release = _release!;
console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`);
@@ -733,14 +741,16 @@ export const listReleaseAssets = async (
/**
* Finds a release by tag name.
*
* Uses the direct getReleaseByTag API for O(1) lookup instead of iterating
* through all releases. This also avoids GitHub's API pagination limit of
* 10000 results which would cause failures for repositories with many releases.
* Uses the direct getReleaseByTag API for O(1) lookup. Because GitHub does not
* expose draft releases through that endpoint, a 404 falls back to a bounded
* scan of recent releases and briefly retries in case the listing is not yet
* consistent.
*
* @param releaser - The GitHub API wrapper for release operations
* @param owner - The owner of the repository
* @param repo - The name of the repository
* @param tag - The tag name to search for
* @param listingAttempts - The maximum number of listing attempts after a direct 404
* @returns The release with the given tag name, or undefined if no release with that tag name is found
*/
export async function findTagFromReleases(
@@ -748,18 +758,35 @@ export async function findTagFromReleases(
owner: string,
repo: string,
tag: string,
listingAttempts: number = 1,
): Promise<Release | undefined> {
try {
const { data: release } = await releaser.getReleaseByTag({ owner, repo, tag });
return release;
} catch (error) {
// Release not found (404) or other error - return undefined to allow creation
if (error.status === 404) {
return undefined;
if (error.status !== 404) {
throw error;
}
// Re-throw unexpected errors
throw error;
}
if (listingAttempts <= 0) {
return undefined;
}
const attempts = Math.max(1, listingAttempts);
for (let attempt = 0; attempt < attempts; attempt += 1) {
const recentReleases = await recentReleasesByTag(releaser, owner, repo, tag);
const canonicalRelease = pickCanonicalRelease(recentReleases, undefined);
if (canonicalRelease) {
return canonicalRelease;
}
if (attempt < attempts - 1) {
await sleep(CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS);
}
}
return undefined;
}
const CREATED_RELEASE_DISCOVERY_RETRY_DELAY_MS = 1000;
@@ -811,33 +838,31 @@ function pickCanonicalRelease(
})[0];
}
async function cleanupDuplicateDraftReleases(
async function cleanupCreatedDuplicateDraftRelease(
releaser: Releaser,
owner: string,
repo: string,
tag: string,
canonicalReleaseId: number,
releases: Release[],
createdRelease: Release,
): Promise<void> {
const uniqueReleases = Array.from(
new Map(releases.map((release) => [release.id, release])).values(),
);
if (
createdRelease.id === canonicalReleaseId ||
!createdRelease.draft ||
createdRelease.assets.length > 0
) {
return;
}
for (const duplicate of uniqueReleases) {
if (duplicate.id === canonicalReleaseId || !duplicate.draft || duplicate.assets.length > 0) {
continue;
}
try {
console.log(`🧹 Removing duplicate draft release ${duplicate.id} for tag ${tag}...`);
await releaser.deleteRelease({
owner,
repo,
release_id: duplicate.id,
});
} catch (error) {
console.warn(`error deleting duplicate release ${duplicate.id}: ${error}`);
}
try {
console.log(`🧹 Removing duplicate draft release ${createdRelease.id} for tag ${tag}...`);
await releaser.deleteRelease({
owner,
repo,
release_id: createdRelease.id,
});
} catch (error) {
console.warn(`error deleting duplicate release ${createdRelease.id}: ${error}`);
}
}
@@ -854,7 +879,7 @@ async function canonicalizeCreatedRelease(
for (let attempt = 1; attempt <= attempts; attempt += 1) {
let releaseByTag: Release | undefined;
try {
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag);
releaseByTag = await findTagFromReleases(releaser, owner, repo, tag, 0);
} catch (error) {
console.warn(`error reloading release for tag ${tag}: ${error}`);
}
@@ -874,10 +899,19 @@ async function canonicalizeCreatedRelease(
);
}
await cleanupDuplicateDraftReleases(releaser, owner, repo, tag, canonicalRelease.id, [
createdRelease,
...recentReleases,
]);
const refreshedCreatedRelease = recentReleases.find(
(release) => release.id === createdRelease.id,
);
if (refreshedCreatedRelease) {
await cleanupCreatedDuplicateDraftRelease(
releaser,
owner,
repo,
tag,
canonicalRelease.id,
refreshedCreatedRelease,
);
}
return canonicalRelease;
}