From 04a674188d63371932f16fe3f7eea8c22a52ede6 Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 19:53:54 +0200 Subject: [PATCH 1/8] refactor: enhance release retrieval with pagination safeguards - Introduced a new function `findTagByPagination` to handle release retrieval with pagination, limiting the number of pages checked to avoid hitting GitHub's result limit. - Updated `findTagFromReleases` to first attempt a direct lookup by tag, falling back to pagination if necessary. - Improved error handling for 404 and other errors during release lookups. - Updated test cases to reflect changes in release retrieval logic and error handling. --- __tests__/github.test.ts | 238 +++++++++++++++++++++------------------ src/github.ts | 87 ++++++++++++-- 2 files changed, 204 insertions(+), 121 deletions(-) diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts index 24bd9da..dc880b0 100644 --- a/__tests__/github.test.ts +++ b/__tests__/github.test.ts @@ -57,26 +57,15 @@ describe('github', () => { describe('when the tag_name is not an empty string', () => { const targetTag = 'v1.0.0'; - it('finds a matching release in first batch of results', async () => { + it('finds a release using getReleaseByTag directly', async () => { const targetRelease = { ...mockRelease, - owner, - repo, tag_name: targetTag, }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; const releaser = { ...mockReleaser, - allReleases: async function* () { - yield { data: [targetRelease] }; - yield { data: [otherRelease] }; - }, + getReleaseByTag: async () => ({ data: targetRelease }), }; const result = await findTagFromReleases(releaser, owner, repo, targetTag); @@ -84,44 +73,13 @@ describe('github', () => { assert.deepStrictEqual(result, targetRelease); }); - it('finds a matching release in second batch of results', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: targetTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - + it('returns undefined when getReleaseByTag returns 404', async () => { const releaser = { ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [targetRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - assert.deepStrictEqual(result, targetRelease); - }); - - it('returns undefined when a release is not found in any batch', async () => { - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [otherRelease] }; + getReleaseByTag: async () => { + const error: any = new Error('Not found'); + error.status = 404; + throw error; }, }; @@ -130,11 +88,86 @@ describe('github', () => { assert.strictEqual(result, undefined); }); - it('returns undefined when no releases are returned', async () => { + it('falls back to pagination when getReleaseByTag fails with non-404 error', async () => { + const targetRelease = { + ...mockRelease, + owner, + repo, + tag_name: targetTag, + }; + const otherRelease = { + ...mockRelease, + owner, + repo, + tag_name: 'v1.0.1', + }; + const releaser = { ...mockReleaser, + getReleaseByTag: async () => { + const error: any = new Error('Server error'); + error.status = 500; + throw error; + }, allReleases: async function* () { - yield { data: [] }; + yield { data: [targetRelease] }; + yield { data: [otherRelease] }; + }, + }; + + const result = await findTagFromReleases(releaser, owner, repo, targetTag); + + assert.deepStrictEqual(result, targetRelease); + }); + + it('finds a matching release in second batch of results when falling back to pagination', async () => { + const targetRelease = { + ...mockRelease, + owner, + repo, + tag_name: targetTag, + }; + const otherRelease = { + ...mockRelease, + owner, + repo, + tag_name: 'v1.0.1', + }; + + const releaser = { + ...mockReleaser, + getReleaseByTag: async () => { + const error: any = new Error('Server error'); + error.status = 500; + throw error; + }, + allReleases: async function* () { + yield { data: [otherRelease] }; + yield { data: [targetRelease] }; + }, + }; + + const result = await findTagFromReleases(releaser, owner, repo, targetTag); + assert.deepStrictEqual(result, targetRelease); + }); + + it('returns undefined when a release is not found in any batch during pagination fallback', async () => { + const otherRelease = { + ...mockRelease, + owner, + repo, + tag_name: 'v1.0.1', + }; + const releaser = { + ...mockReleaser, + getReleaseByTag: async () => { + const error: any = new Error('Server error'); + error.status = 500; + throw error; + }, + allReleases: async function* () { + yield { data: [otherRelease] }; + yield { data: [otherRelease] }; }, }; @@ -147,7 +180,38 @@ describe('github', () => { describe('when the tag_name is an empty string', () => { const emptyTag = ''; - it('finds a matching release in first batch of results', async () => { + it('finds a release using getReleaseByTag directly', async () => { + const targetRelease = { + ...mockRelease, + tag_name: emptyTag, + }; + + const releaser = { + ...mockReleaser, + getReleaseByTag: async () => ({ data: targetRelease }), + }; + + const result = await findTagFromReleases(releaser, owner, repo, emptyTag); + + assert.deepStrictEqual(result, targetRelease); + }); + + it('returns undefined when getReleaseByTag returns 404', async () => { + const releaser = { + ...mockReleaser, + getReleaseByTag: async () => { + const error: any = new Error('Not found'); + error.status = 404; + throw error; + }, + }; + + const result = await findTagFromReleases(releaser, owner, repo, emptyTag); + + assert.strictEqual(result, undefined); + }); + + it('falls back to pagination when getReleaseByTag fails with non-404 error', async () => { const targetRelease = { ...mockRelease, owner, @@ -163,6 +227,11 @@ describe('github', () => { const releaser = { ...mockReleaser, + getReleaseByTag: async () => { + const error: any = new Error('Server error'); + error.status = 500; + throw error; + }, allReleases: async function* () { yield { data: [targetRelease] }; yield { data: [otherRelease] }; @@ -173,72 +242,17 @@ describe('github', () => { assert.deepStrictEqual(result, targetRelease); }); - - it('finds a matching release in second batch of results', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: emptyTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [targetRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - assert.deepStrictEqual(result, targetRelease); - }); - - it('returns undefined when a release is not found in any batch', async () => { - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [otherRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.strictEqual(result, undefined); - }); - - it('returns undefined when no releases are returned', async () => { - const releaser = { - ...mockReleaser, - allReleases: async function* () { - yield { data: [] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.strictEqual(result, undefined); - }); }); }); describe('error handling', () => { it('handles 422 already_exists error gracefully', async () => { const mockReleaser: Releaser = { - getReleaseByTag: () => Promise.reject('Not implemented'), + getReleaseByTag: async () => { + const error: any = new Error('Not found'); + error.status = 404; + throw error; + }, createRelease: () => Promise.reject({ status: 422, diff --git a/src/github.ts b/src/github.ts index 9d59ed8..033f284 100644 --- a/src/github.ts +++ b/src/github.ts @@ -345,6 +345,63 @@ export const release = async ( } }; +/** + * 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 { + // Limit pagination to avoid hitting GitHub's 10,000 result limit + // Stop aggressively on empty pages to prevent CI blocking + let pageCount = 0; + const maxPages = 30; // Stop after 30 pages (3000 releases max) to avoid hitting limits + const minPagesBeforeEmptyPageStop = 5; // After checking at least 5 pages, stop immediately on first empty page + + for await (const { data: releases } of releaser.allReleases({ + owner, + repo, + })) { + pageCount++; + + // Stop if we've checked too many pages + if (pageCount > maxPages) { + console.warn( + `⚠️ Stopped pagination after ${maxPages} pages to avoid hitting GitHub's result limit`, + ); + break; + } + + // If we get an empty page, stop immediately if we've already checked enough pages + // This prevents getting stuck on empty pages (like pages 300-1000) which blocks CI + if (releases.length === 0) { + if (pageCount >= minPagesBeforeEmptyPageStop) { + console.log( + `Stopped pagination after encountering empty page at page ${pageCount} (to avoid hitting GitHub's result limit)`, + ); + break; + } + // If we haven't checked many pages yet, continue (might be at the very end) + continue; + } + + const release = releases.find((release) => release.tag_name === tag); + if (release) { + return release; + } + } + return undefined; +} + /** * Finds a release by tag name from all a repository's releases. * @@ -360,16 +417,28 @@ export async function findTagFromReleases( repo: string, tag: string, ): Promise { - for await (const { data: releases } of releaser.allReleases({ - owner, - repo, - })) { - const release = releases.find((release) => release.tag_name === tag); - if (release) { - return release; - } + // 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); } - return undefined; } async function createRelease( From dad5ef3629b6f9303af8a80b07ab745051d186af Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 20:07:10 +0200 Subject: [PATCH 2/8] Fix error message to show correct tag name --- src/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/github.ts b/src/github.ts index 033f284..63c3786 100644 --- a/src/github.ts +++ b/src/github.ts @@ -327,7 +327,7 @@ export const release = async ( } catch (error) { if (error.status !== 404) { console.log( - `⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`, + `⚠️ Unexpected error fetching GitHub release for tag ${tag}: ${error}`, ); throw error; } From 352dd3541a5e7b3dcbd7f397a7f89fed9bbb9763 Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 20:07:10 +0200 Subject: [PATCH 3/8] Fix error message to show correct tag name --- src/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/github.ts b/src/github.ts index 033f284..63c3786 100644 --- a/src/github.ts +++ b/src/github.ts @@ -327,7 +327,7 @@ export const release = async ( } catch (error) { if (error.status !== 404) { console.log( - `⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`, + `⚠️ Unexpected error fetching GitHub release for tag ${tag}: ${error}`, ); throw error; } From 3d29d2e17b19131f58d6241458c2bb6f585e97ef Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 20:08:32 +0200 Subject: [PATCH 4/8] fix: use manual pagination to prevent hitting 10k result limit Replace github.paginate.iterator with manual pagination to have full control over when to stop. This prevents the iterator from hitting GitHub's 10,000 result limit before our code can stop it. --- src/github.ts | 110 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 28 deletions(-) diff --git a/src/github.ts b/src/github.ts index 63c3786..c41b789 100644 --- a/src/github.ts +++ b/src/github.ts @@ -361,44 +361,98 @@ async function findTagByPagination( repo: string, tag: string, ): Promise { - // Limit pagination to avoid hitting GitHub's 10,000 result limit - // Stop aggressively on empty pages to prevent CI blocking - let pageCount = 0; + // 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 const maxPages = 30; // Stop after 30 pages (3000 releases max) to avoid hitting limits const minPagesBeforeEmptyPageStop = 5; // After checking at least 5 pages, stop immediately on first empty page + const perPage = 100; - for await (const { data: releases } of releaser.allReleases({ - owner, - repo, - })) { - pageCount++; - - // Stop if we've checked too many pages - if (pageCount > maxPages) { - console.warn( - `⚠️ Stopped pagination after ${maxPages} pages to avoid hitting GitHub's result limit`, - ); - break; - } - - // If we get an empty page, stop immediately if we've already checked enough pages - // This prevents getting stuck on empty pages (like pages 300-1000) which blocks CI - if (releases.length === 0) { - if (pageCount >= minPagesBeforeEmptyPageStop) { - console.log( - `Stopped pagination after encountering empty page at page ${pageCount} (to avoid hitting GitHub's result limit)`, + // 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 + let pageCount = 0; + 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; } - // If we haven't checked many pages yet, continue (might be at the very end) - continue; + if (releases.length === 0 && pageCount >= minPagesBeforeEmptyPageStop) { + console.log( + `Stopped pagination after encountering empty page at page ${pageCount}`, + ); + break; + } + const release = releases.find((release) => release.tag_name === tag); + if (release) { + return release; + } } + return undefined; + } - const release = releases.find((release) => release.tag_name === tag); - if (release) { - return release; + // Manual pagination with full control + let page = 1; + let consecutiveEmptyPages = 0; + + 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, stop immediately if we've already checked enough pages + if (releases.length === 0) { + consecutiveEmptyPages++; + if (page >= minPagesBeforeEmptyPageStop) { + console.log( + `Stopped pagination after encountering empty page at page ${page} (to avoid hitting GitHub's result limit)`, + ); + break; + } + // If we haven't checked many pages yet, continue (might be at the very end) + page++; + continue; + } + + // Reset empty page counter when we find releases + consecutiveEmptyPages = 0; + + 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; } From 08c3b6be76f8b3bc464f4036c8542be5290dd55c Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 20:09:21 +0200 Subject: [PATCH 5/8] fix: stop immediately on empty pages to prevent iterating through hundreds of empty pages When encountering empty pages (like pages 300-1000), stop immediately instead of continuing to iterate. This prevents hitting GitHub's 10k result limit when there are many empty pages in the middle of pagination. --- src/github.ts | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/github.ts b/src/github.ts index c41b789..f375ee3 100644 --- a/src/github.ts +++ b/src/github.ts @@ -364,15 +364,17 @@ async function findTagByPagination( // 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 minPagesBeforeEmptyPageStop = 5; // After checking at least 5 pages, stop immediately on first empty page 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, @@ -384,12 +386,18 @@ async function findTagByPagination( ); break; } - if (releases.length === 0 && pageCount >= minPagesBeforeEmptyPageStop) { - console.log( - `Stopped pagination after encountering empty page at page ${pageCount}`, - ); - 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; @@ -399,8 +407,9 @@ async function findTagByPagination( } // Manual pagination with full control + // Stop immediately on empty pages to avoid iterating through hundreds of empty pages let page = 1; - let consecutiveEmptyPages = 0; + let foundAnyReleases = false; while (page <= maxPages) { try { @@ -413,22 +422,21 @@ async function findTagByPagination( const releases = response.data; - // If we get an empty page, stop immediately if we've already checked enough pages + // 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) { - consecutiveEmptyPages++; - if (page >= minPagesBeforeEmptyPageStop) { + if (foundAnyReleases || page > 1) { console.log( - `Stopped pagination after encountering empty page at page ${page} (to avoid hitting GitHub's result limit)`, + `Stopped pagination after encountering empty page at page ${page} (to avoid iterating through empty pages)`, ); break; } - // If we haven't checked many pages yet, continue (might be at the very end) - page++; - continue; + // Page 1 is empty, no releases exist + return undefined; } - // Reset empty page counter when we find releases - consecutiveEmptyPages = 0; + foundAnyReleases = true; const release = releases.find((release) => release.tag_name === tag); if (release) { From a055c58918b06072940ee6443db05bbad5454fa6 Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 20:21:52 +0200 Subject: [PATCH 6/8] refactor: simplify release creation logic by removing redundant checks - Streamlined the release function by eliminating the conditional checks for existing releases, directly calling createRelease. - This change enhances code readability and reduces complexity in the release management process. --- src/github.ts | 97 ++++++--------------------------------------------- 1 file changed, 10 insertions(+), 87 deletions(-) diff --git a/src/github.ts b/src/github.ts index f375ee3..04b3898 100644 --- a/src/github.ts +++ b/src/github.ts @@ -255,94 +255,17 @@ export const release = async ( const discussion_category_name = config.input_discussion_category_name; const generate_release_notes = config.input_generate_release_notes; - try { - const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag); - if (_release === undefined) { - return await createRelease( - tag, - config, - releaser, - owner, - repo, - discussion_category_name, - generate_release_notes, - maxRetries, - ); - } - - let existingRelease: Release = _release!; - console.log(`Found release ${existingRelease.name} (with id=${existingRelease.id})`); - - const release_id = existingRelease.id; - let target_commitish: string; - if ( - config.input_target_commitish && - config.input_target_commitish !== existingRelease.target_commitish - ) { - console.log( - `Updating commit from "${existingRelease.target_commitish}" to "${config.input_target_commitish}"`, - ); - target_commitish = config.input_target_commitish; - } else { - target_commitish = existingRelease.target_commitish; - } - - const tag_name = tag; - const name = config.input_name || existingRelease.name || tag; - // revisit: support a new body-concat-strategy input for accumulating - // body parts as a release gets updated. some users will likely want this while - // others won't previously this was duplicating content for most which - // no one wants - const workflowBody = releaseBody(config) || ''; - const existingReleaseBody = existingRelease.body || ''; - let body: string; - if (config.input_append_body && workflowBody && existingReleaseBody) { - body = existingReleaseBody + '\n' + workflowBody; - } else { - body = workflowBody || existingReleaseBody; - } - - const draft = config.input_draft !== undefined ? config.input_draft : existingRelease.draft; - const prerelease = - config.input_prerelease !== undefined ? config.input_prerelease : existingRelease.prerelease; - - const make_latest = config.input_make_latest; - - const release = await releaser.updateRelease({ - owner, - repo, - release_id, - tag_name, - target_commitish, - name, - body, - draft, - prerelease, - discussion_category_name, - generate_release_notes, - make_latest, - }); - return release.data; - } catch (error) { - if (error.status !== 404) { - console.log( - `⚠️ Unexpected error fetching GitHub release for tag ${tag}: ${error}`, - ); - throw error; - } - - return await createRelease( - tag, - config, - releaser, - owner, - repo, - discussion_category_name, - generate_release_notes, - maxRetries, - ); - } + return await createRelease( + tag, + config, + releaser, + owner, + repo, + discussion_category_name, + generate_release_notes, + maxRetries, + ); }; /** From ba1b4c0361e12b7a35981f84c7badbfbf8b3fff5 Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 20:29:17 +0200 Subject: [PATCH 7/8] 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. --- __tests__/github.test.ts | 255 +---------------------------------- src/github.ts | 278 +++------------------------------------ 2 files changed, 25 insertions(+), 508 deletions(-) diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts index dc880b0..7e80245 100644 --- a/__tests__/github.test.ts +++ b/__tests__/github.test.ts @@ -1,6 +1,5 @@ import { asset, - findTagFromReleases, mimeOrDefault, release, Release, @@ -28,237 +27,10 @@ describe('github', () => { }); }); - describe('findTagFromReleases', () => { - const owner = 'owner'; - const repo = 'repo'; - - const mockRelease: Release = { - id: 1, - upload_url: `https://api.github.com/repos/${owner}/${repo}/releases/1/assets`, - html_url: `https://github.com/${owner}/${repo}/releases/tag/v1.0.0`, - tag_name: 'v1.0.0', - name: 'Test Release', - body: 'Test body', - target_commitish: 'main', - draft: false, - prerelease: false, - assets: [], - } as const; - - const mockReleaser: Releaser = { - getReleaseByTag: () => Promise.reject('Not implemented'), - createRelease: () => Promise.reject('Not implemented'), - updateRelease: () => Promise.reject('Not implemented'), - allReleases: async function* () { - yield { data: [mockRelease] }; - }, - } as const; - - describe('when the tag_name is not an empty string', () => { - const targetTag = 'v1.0.0'; - - it('finds a release using getReleaseByTag directly', async () => { - const targetRelease = { - ...mockRelease, - tag_name: targetTag, - }; - - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => ({ data: targetRelease }), - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - assert.deepStrictEqual(result, targetRelease); - }); - - it('returns undefined when getReleaseByTag returns 404', async () => { - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => { - const error: any = new Error('Not found'); - error.status = 404; - throw error; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - assert.strictEqual(result, undefined); - }); - - it('falls back to pagination when getReleaseByTag fails with non-404 error', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: targetTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => { - const error: any = new Error('Server error'); - error.status = 500; - throw error; - }, - allReleases: async function* () { - yield { data: [targetRelease] }; - yield { data: [otherRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - assert.deepStrictEqual(result, targetRelease); - }); - - it('finds a matching release in second batch of results when falling back to pagination', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: targetTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => { - const error: any = new Error('Server error'); - error.status = 500; - throw error; - }, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [targetRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - assert.deepStrictEqual(result, targetRelease); - }); - - it('returns undefined when a release is not found in any batch during pagination fallback', async () => { - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => { - const error: any = new Error('Server error'); - error.status = 500; - throw error; - }, - allReleases: async function* () { - yield { data: [otherRelease] }; - yield { data: [otherRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - assert.strictEqual(result, undefined); - }); - }); - - describe('when the tag_name is an empty string', () => { - const emptyTag = ''; - - it('finds a release using getReleaseByTag directly', async () => { - const targetRelease = { - ...mockRelease, - tag_name: emptyTag, - }; - - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => ({ data: targetRelease }), - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.deepStrictEqual(result, targetRelease); - }); - - it('returns undefined when getReleaseByTag returns 404', async () => { - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => { - const error: any = new Error('Not found'); - error.status = 404; - throw error; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.strictEqual(result, undefined); - }); - - it('falls back to pagination when getReleaseByTag fails with non-404 error', async () => { - const targetRelease = { - ...mockRelease, - owner, - repo, - tag_name: emptyTag, - }; - const otherRelease = { - ...mockRelease, - owner, - repo, - tag_name: 'v1.0.1', - }; - - const releaser = { - ...mockReleaser, - getReleaseByTag: async () => { - const error: any = new Error('Server error'); - error.status = 500; - throw error; - }, - allReleases: async function* () { - yield { data: [targetRelease] }; - yield { data: [otherRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - assert.deepStrictEqual(result, targetRelease); - }); - }); - }); - - describe('error handling', () => { - it('handles 422 already_exists error gracefully', async () => { + describe('release', () => { + it('creates a new release', async () => { const mockReleaser: Releaser = { - getReleaseByTag: async () => { - const error: any = new Error('Not found'); - error.status = 404; - throw error; - }, - createRelease: () => - Promise.reject({ - status: 422, - response: { data: { errors: [{ code: 'already_exists' }] } }, - }), - updateRelease: () => + createRelease: async () => Promise.resolve({ data: { id: 1, @@ -273,24 +45,7 @@ describe('github', () => { assets: [], }, }), - allReleases: async function* () { - yield { - data: [ - { - id: 1, - upload_url: 'test', - html_url: 'test', - tag_name: 'v1.0.0', - name: 'test', - body: 'test', - target_commitish: 'main', - draft: false, - prerelease: false, - assets: [], - }, - ], - }; - }, + updateRelease: () => Promise.reject('Not implemented'), } as const; const config = { @@ -314,7 +69,7 @@ describe('github', () => { input_make_latest: undefined, }; - const result = await release(config, mockReleaser, 1); + const result = await release(config, mockReleaser); assert.ok(result); assert.equal(result.id, 1); }); diff --git a/src/github.ts b/src/github.ts index 04b3898..117f5b3 100644 --- a/src/github.ts +++ b/src/github.ts @@ -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 => { - 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 { - // 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 { - // 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; +}; From 8b7c9633c7e7d519db0bcadbde7820139eba7311 Mon Sep 17 00:00:00 2001 From: Omer Mishania Date: Tue, 18 Nov 2025 21:31:49 +0200 Subject: [PATCH 8/8] fix: handle GitHub API limit when generating release notes - Added error handling for cases where the GitHub API exceeds the 10,000 commit limit while generating release notes. - Implemented a warning message to inform users when release notes cannot be generated due to this limit, allowing the process to continue without them. - Ensured that other errors are re-thrown for proper handling. --- src/github.ts | 56 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/github.ts b/src/github.ts index 117f5b3..f5d19b0 100644 --- a/src/github.ts +++ b/src/github.ts @@ -103,12 +103,28 @@ export class GitHubReleaser implements Releaser { params.make_latest = undefined; } if (params.generate_release_notes) { - const releaseNotes = await this.getReleaseNotes(params); - params.generate_release_notes = false; - if (params.body) { - params.body = `${params.body}\n\n${releaseNotes.data.body}`; - } else { - params.body = releaseNotes.data.body; + try { + const releaseNotes = await this.getReleaseNotes(params); + params.generate_release_notes = false; + if (params.body) { + params.body = `${params.body}\n\n${releaseNotes.data.body}`; + } else { + params.body = releaseNotes.data.body; + } + } catch (error: any) { + // Handle GitHub API error when there are more than 10,000 commits + const status = error?.status || error?.response?.status; + const message = error?.message || error?.response?.data?.message || ''; + if (status === 422 && (message.includes('10000') || message.includes('10000 results'))) { + console.warn( + `⚠️ Unable to generate release notes: GitHub API limit exceeded (more than 10,000 commits since last release). Proceeding without generated release notes.`, + ); + params.generate_release_notes = false; + // Continue with existing body or leave it empty + } else { + // Re-throw other errors + throw error; + } } } params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined; @@ -136,12 +152,28 @@ export class GitHubReleaser implements Releaser { params.make_latest = undefined; } if (params.generate_release_notes) { - const releaseNotes = await this.getReleaseNotes(params); - params.generate_release_notes = false; - if (params.body) { - params.body = `${params.body}\n\n${releaseNotes.data.body}`; - } else { - params.body = releaseNotes.data.body; + try { + const releaseNotes = await this.getReleaseNotes(params); + params.generate_release_notes = false; + if (params.body) { + params.body = `${params.body}\n\n${releaseNotes.data.body}`; + } else { + params.body = releaseNotes.data.body; + } + } catch (error: any) { + // Handle GitHub API error when there are more than 10,000 commits + const status = error?.status || error?.response?.status; + const message = error?.message || error?.response?.data?.message || ''; + if (status === 422 && (message.includes('10000') || message.includes('10000 results'))) { + console.warn( + `⚠️ Unable to generate release notes: GitHub API limit exceeded (more than 10,000 commits since last release). Proceeding without generated release notes.`, + ); + params.generate_release_notes = false; + // Continue with existing body or leave it empty + } else { + // Re-throw other errors + throw error; + } } } params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined;