This commit is contained in:
Omer Mishania 2025-11-18 19:31:55 +00:00 committed by GitHub
commit a70c4f1f45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 69 additions and 452 deletions

View file

@ -1,6 +1,5 @@
import { import {
asset, asset,
findTagFromReleases,
mimeOrDefault, mimeOrDefault,
release, release,
Release, Release,
@ -28,223 +27,10 @@ describe('github', () => {
}); });
}); });
describe('findTagFromReleases', () => { describe('release', () => {
const owner = 'owner'; it('creates a new release', async () => {
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 = { const mockReleaser: Releaser = {
getReleaseByTag: () => Promise.reject('Not implemented'), createRelease: async () =>
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 () => {
const mockReleaser: Releaser = {
getReleaseByTag: () => Promise.reject('Not implemented'),
createRelease: () =>
Promise.reject({
status: 422,
response: { data: { errors: [{ code: 'already_exists' }] } },
}),
updateRelease: () =>
Promise.resolve({ Promise.resolve({
data: { data: {
id: 1, id: 1,
@ -259,24 +45,7 @@ describe('github', () => {
assets: [], assets: [],
}, },
}), }),
allReleases: async function* () { updateRelease: () => Promise.reject('Not implemented'),
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: [],
},
],
};
},
} as const; } as const;
const config = { const config = {
@ -300,7 +69,7 @@ describe('github', () => {
input_make_latest: undefined, input_make_latest: undefined,
}; };
const result = await release(config, mockReleaser, 1); const result = await release(config, mockReleaser);
assert.ok(result); assert.ok(result);
assert.equal(result.id, 1); assert.equal(result.id, 1);
}); });

View file

@ -27,8 +27,6 @@ export interface Release {
} }
export interface Releaser { export interface Releaser {
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
createRelease(params: { createRelease(params: {
owner: string; owner: string;
repo: string; repo: string;
@ -57,8 +55,6 @@ export interface Releaser {
generate_release_notes: boolean | undefined; generate_release_notes: boolean | undefined;
make_latest: 'true' | 'false' | 'legacy' | undefined; make_latest: 'true' | 'false' | 'legacy' | undefined;
}): Promise<{ data: Release }>; }): Promise<{ data: Release }>;
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }>;
} }
export class GitHubReleaser implements Releaser { export class GitHubReleaser implements Releaser {
@ -67,14 +63,6 @@ export class GitHubReleaser implements Releaser {
this.github = github; this.github = github;
} }
getReleaseByTag(params: {
owner: string;
repo: string;
tag: string;
}): Promise<{ data: Release }> {
return this.github.rest.repos.getReleaseByTag(params);
}
async getReleaseNotes(params: { async getReleaseNotes(params: {
owner: string; owner: string;
repo: string; repo: string;
@ -115,6 +103,7 @@ export class GitHubReleaser implements Releaser {
params.make_latest = undefined; params.make_latest = undefined;
} }
if (params.generate_release_notes) { if (params.generate_release_notes) {
try {
const releaseNotes = await this.getReleaseNotes(params); const releaseNotes = await this.getReleaseNotes(params);
params.generate_release_notes = false; params.generate_release_notes = false;
if (params.body) { if (params.body) {
@ -122,6 +111,21 @@ export class GitHubReleaser implements Releaser {
} else { } else {
params.body = releaseNotes.data.body; 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; params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined;
return this.github.rest.repos.createRelease(params); return this.github.rest.repos.createRelease(params);
@ -148,6 +152,7 @@ export class GitHubReleaser implements Releaser {
params.make_latest = undefined; params.make_latest = undefined;
} }
if (params.generate_release_notes) { if (params.generate_release_notes) {
try {
const releaseNotes = await this.getReleaseNotes(params); const releaseNotes = await this.getReleaseNotes(params);
params.generate_release_notes = false; params.generate_release_notes = false;
if (params.body) { if (params.body) {
@ -155,17 +160,25 @@ export class GitHubReleaser implements Releaser {
} else { } else {
params.body = releaseNotes.data.body; 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; params.body = params.body ? this.truncateReleaseNotes(params.body) : undefined;
return this.github.rest.repos.updateRelease(params); 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 => { export const asset = (path: string): ReleaseAsset => {
@ -241,161 +254,29 @@ export const upload = async (
export const release = async ( export const release = async (
config: Config, config: Config,
releaser: Releaser, releaser: Releaser,
maxRetries: number = 3,
): Promise<Release> => { ): 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 [owner, repo] = config.github_repository.split('/');
const tag = const tag =
config.input_tag_name || config.input_tag_name ||
(isTag(config.github_ref) ? config.github_ref.replace('refs/tags/', '') : ''); (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<Release | undefined> {
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 tag_name = tag;
const name = config.input_name || tag; const name = config.input_name || tag;
const body = releaseBody(config); const body = releaseBody(config);
const draft = config.input_draft; const draft = config.input_draft;
const prerelease = config.input_prerelease; const prerelease = config.input_prerelease;
const target_commitish = config.input_target_commitish; 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; const make_latest = config.input_make_latest;
let commitMessage: string = ''; let commitMessage: string = '';
if (target_commitish) { if (target_commitish) {
commitMessage = ` using commit "${target_commitish}"`; commitMessage = ` using commit "${target_commitish}"`;
} }
console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`); console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
try {
let release = await releaser.createRelease({ const release = await releaser.createRelease({
owner, owner,
repo, repo,
tag_name, tag_name,
@ -408,39 +289,6 @@ async function createRelease(
generate_release_notes, generate_release_notes,
make_latest, make_latest,
}); });
return release.data; 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);
}
}