diff --git a/__tests__/github.test.ts b/__tests__/github.test.ts index 24bd9da..7e80245 100644 --- a/__tests__/github.test.ts +++ b/__tests__/github.test.ts @@ -1,6 +1,5 @@ import { asset, - findTagFromReleases, mimeOrDefault, release, Release, @@ -28,223 +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 matching release in first batch of results', 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] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - 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', - }; - - 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] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, targetTag); - - 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, targetTag); - - assert.strictEqual(result, undefined); - }); - }); - - describe('when the tag_name is an empty string', () => { - const emptyTag = ''; - - it('finds a matching release in first 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: [targetRelease] }; - yield { data: [otherRelease] }; - }, - }; - - const result = await findTagFromReleases(releaser, owner, repo, emptyTag); - - 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 () => { + describe('release', () => { + it('creates a new release', async () => { const mockReleaser: Releaser = { - getReleaseByTag: () => Promise.reject('Not implemented'), - createRelease: () => - Promise.reject({ - status: 422, - response: { data: { errors: [{ code: 'already_exists' }] } }, - }), - updateRelease: () => + createRelease: async () => Promise.resolve({ data: { id: 1, @@ -259,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 = { @@ -300,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 9d59ed8..f5d19b0 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; @@ -115,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; @@ -148,24 +152,33 @@ 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; 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,206 +254,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; - 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 ${config.github_ref}: ${error}`, - ); - throw error; - } - - return await createRelease( - tag, - config, - releaser, - owner, - repo, - discussion_category_name, - generate_release_notes, - maxRetries, - ); - } -}; - -/** - * 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 { - for await (const { data: releases } of releaser.allReleases({ - owner, - repo, - })) { - const release = releases.find((release) => release.tag_name === tag); - if (release) { - return release; - } - } - return undefined; -} - -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; +};