refactor: remove findTagFromReleases and related tests for streamlined release management

- Eliminated the `findTagFromReleases` function and its associated tests to simplify the release process.
- Updated the `release` function to directly call `createRelease`, enhancing code clarity and reducing complexity.
- Adjusted tests to reflect the removal of the `findTagFromReleases` functionality.
This commit is contained in:
Omer Mishania 2025-11-18 20:29:17 +02:00
parent a055c58918
commit ba1b4c0361
2 changed files with 25 additions and 508 deletions

View file

@ -27,8 +27,6 @@ export interface Release {
}
export interface Releaser {
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
createRelease(params: {
owner: string;
repo: string;
@ -57,8 +55,6 @@ export interface Releaser {
generate_release_notes: boolean | undefined;
make_latest: 'true' | 'false' | 'legacy' | undefined;
}): Promise<{ data: Release }>;
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }>;
}
export class GitHubReleaser implements Releaser {
@ -67,14 +63,6 @@ export class GitHubReleaser implements Releaser {
this.github = github;
}
getReleaseByTag(params: {
owner: string;
repo: string;
tag: string;
}): Promise<{ data: Release }> {
return this.github.rest.repos.getReleaseByTag(params);
}
async getReleaseNotes(params: {
owner: string;
repo: string;
@ -159,13 +147,6 @@ export class GitHubReleaser implements Releaser {
params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined;
return this.github.rest.repos.updateRelease(params);
}
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }> {
const updatedParams = { per_page: 100, ...params };
return this.github.paginate.iterator(
this.github.rest.repos.listReleases.endpoint.merge(updatedParams),
);
}
}
export const asset = (path: string): ReleaseAsset => {
@ -241,260 +222,41 @@ export const upload = async (
export const release = async (
config: Config,
releaser: Releaser,
maxRetries: number = 3,
): Promise<Release> => {
if (maxRetries <= 0) {
console.log(`❌ Too many retries. Aborting...`);
throw new Error('Too many retries.');
}
const [owner, repo] = config.github_repository.split('/');
const tag =
config.input_tag_name ||
(isTag(config.github_ref) ? config.github_ref.replace('refs/tags/', '') : '');
const discussion_category_name = config.input_discussion_category_name;
const generate_release_notes = config.input_generate_release_notes;
return await createRelease(
tag,
config,
releaser,
owner,
repo,
discussion_category_name,
generate_release_notes,
maxRetries,
);
};
/**
* Paginates through releases with safeguards to avoid hitting GitHub's 10,000 result limit.
* Stops early if encountering too many consecutive empty pages.
*
* @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
* @returns The release with the given tag name, or undefined if no release with that tag name is found
*/
async function findTagByPagination(
releaser: Releaser,
owner: string,
repo: string,
tag: string,
): Promise<Release | undefined> {
// Manually paginate to avoid hitting GitHub's 10,000 result limit
// The github.paginate.iterator can hit the limit before we can stop it
// So we manually paginate with strict limits
// Stop immediately on empty pages to avoid iterating through hundreds of empty pages
const maxPages = 30; // Stop after 30 pages (3000 releases max) to avoid hitting limits
const perPage = 100;
// Use the GitHub API directly for manual pagination
const github = (releaser as GitHubReleaser).github;
if (!github) {
// Fallback to iterator if we can't access github directly
// Stop immediately on empty pages to avoid iterating through hundreds of empty pages
let pageCount = 0;
let foundAnyReleases = false;
for await (const { data: releases } of releaser.allReleases({
owner,
repo,
})) {
pageCount++;
if (pageCount > maxPages) {
console.warn(
`⚠️ Stopped pagination after ${maxPages} pages to avoid hitting GitHub's result limit`,
);
break;
}
// Stop immediately on empty pages if we've found releases before
if (releases.length === 0) {
if (foundAnyReleases || pageCount > 1) {
console.log(
`Stopped pagination after encountering empty page at page ${pageCount} (to avoid iterating through empty pages)`,
);
break;
}
// Page 1 is empty, no releases exist
return undefined;
}
foundAnyReleases = true;
const release = releases.find((release) => release.tag_name === tag);
if (release) {
return release;
}
}
return undefined;
}
// Manual pagination with full control
// Stop immediately on empty pages to avoid iterating through hundreds of empty pages
let page = 1;
let foundAnyReleases = false;
while (page <= maxPages) {
try {
const response = await github.rest.repos.listReleases({
owner,
repo,
per_page: perPage,
page: page,
});
const releases = response.data;
// If we get an empty page:
// - If we've found releases before, stop immediately (we've hit a gap or the end)
// - If page 1 is empty, that's fine (no releases exist), return undefined
if (releases.length === 0) {
if (foundAnyReleases || page > 1) {
console.log(
`Stopped pagination after encountering empty page at page ${page} (to avoid iterating through empty pages)`,
);
break;
}
// Page 1 is empty, no releases exist
return undefined;
}
foundAnyReleases = true;
const release = releases.find((release) => release.tag_name === tag);
if (release) {
return release;
}
// If we got fewer results than per_page, we've reached the end
if (releases.length < perPage) {
break;
}
page++;
} catch (error: any) {
// If we hit the 10,000 result limit, stop immediately
if (error.status === 422 && error.message?.includes('10000')) {
console.warn(
`⚠️ Stopped pagination at page ${page} due to GitHub's 10,000 result limit`,
);
break;
}
throw error;
}
}
return undefined;
}
/**
* Finds a release by tag name from all a repository's releases.
*
* @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
* @returns The release with the given tag name, or undefined if no release with that tag name is found
*/
export async function findTagFromReleases(
releaser: Releaser,
owner: string,
repo: string,
tag: string,
): Promise<Release | undefined> {
// If tag is empty, skip direct lookup and go straight to pagination
// (some releases may not have tags)
if (!tag) {
return await findTagByPagination(releaser, owner, repo, tag);
}
// First try to get the release directly by tag (much more efficient than paginating)
try {
const { data } = await releaser.getReleaseByTag({ owner, repo, tag });
return data;
} catch (error: any) {
// If the release doesn't exist (404), return undefined
// For other errors, fall back to pagination as a safety measure
if (error.status === 404) {
return undefined;
}
// For non-404 errors, fall back to pagination (though this should rarely happen)
console.warn(
`⚠️ Direct tag lookup failed (status: ${error.status}), falling back to pagination...`,
);
return await findTagByPagination(releaser, owner, repo, tag);
}
}
async function createRelease(
tag: string,
config: Config,
releaser: Releaser,
owner: string,
repo: string,
discussion_category_name: string | undefined,
generate_release_notes: boolean | undefined,
maxRetries: number,
) {
const tag_name = tag;
const name = config.input_name || tag;
const body = releaseBody(config);
const draft = config.input_draft;
const prerelease = config.input_prerelease;
const target_commitish = config.input_target_commitish;
const discussion_category_name = config.input_discussion_category_name;
const generate_release_notes = config.input_generate_release_notes;
const make_latest = config.input_make_latest;
let commitMessage: string = '';
if (target_commitish) {
commitMessage = ` using commit "${target_commitish}"`;
}
console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
try {
let release = await releaser.createRelease({
owner,
repo,
tag_name,
name,
body,
draft,
prerelease,
target_commitish,
discussion_category_name,
generate_release_notes,
make_latest,
});
return release.data;
} catch (error) {
// presume a race with competing matrix runs
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
console.log(`${JSON.stringify(error.response.data)}`);
switch (error.status) {
case 403:
console.log(
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
);
throw error;
case 404:
console.log('Skip retry - discussion category mismatch');
throw error;
case 422:
// Check if this is a race condition with "already_exists" error
const errorData = error.response?.data;
if (errorData?.errors?.[0]?.code === 'already_exists') {
console.log(
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
);
// Don't throw - allow retry to find existing release
} else {
console.log('Skip retry - validation failed');
throw error;
}
break;
}
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
return release(config, releaser, maxRetries - 1);
}
}
const release = await releaser.createRelease({
owner,
repo,
tag_name,
name,
body,
draft,
prerelease,
target_commitish,
discussion_category_name,
generate_release_notes,
make_latest,
});
return release.data;
};