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