mirror of
https://github.com/softprops/action-gh-release.git
synced 2025-11-23 11:50:51 +00:00
refactor: remove findTagFromReleases and related tests for streamlined release management
- Eliminated the `findTagFromReleases` function and its associated tests to simplify the release process. - Updated the `release` function to directly call `createRelease`, enhancing code clarity and reducing complexity. - Adjusted tests to reflect the removal of the `findTagFromReleases` functionality.
This commit is contained in:
parent
a055c58918
commit
ba1b4c0361
2 changed files with 25 additions and 508 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
278
src/github.ts
278
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<Release> => {
|
||||
if (maxRetries <= 0) {
|
||||
console.log(`❌ Too many retries. Aborting...`);
|
||||
throw new Error('Too many retries.');
|
||||
}
|
||||
|
||||
const [owner, repo] = config.github_repository.split('/');
|
||||
const tag =
|
||||
config.input_tag_name ||
|
||||
(isTag(config.github_ref) ? config.github_ref.replace('refs/tags/', '') : '');
|
||||
|
||||
const discussion_category_name = config.input_discussion_category_name;
|
||||
const generate_release_notes = config.input_generate_release_notes;
|
||||
|
||||
return await createRelease(
|
||||
tag,
|
||||
config,
|
||||
releaser,
|
||||
owner,
|
||||
repo,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
maxRetries,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginates through releases with safeguards to avoid hitting GitHub's 10,000 result limit.
|
||||
* Stops early if encountering too many consecutive empty pages.
|
||||
*
|
||||
* @param releaser - The GitHub API wrapper for release operations
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The name of the repository
|
||||
* @param tag - The tag name to search for
|
||||
* @returns The release with the given tag name, or undefined if no release with that tag name is found
|
||||
*/
|
||||
async function findTagByPagination(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
): Promise<Release | undefined> {
|
||||
// Manually paginate to avoid hitting GitHub's 10,000 result limit
|
||||
// The github.paginate.iterator can hit the limit before we can stop it
|
||||
// So we manually paginate with strict limits
|
||||
// Stop immediately on empty pages to avoid iterating through hundreds of empty pages
|
||||
const maxPages = 30; // Stop after 30 pages (3000 releases max) to avoid hitting limits
|
||||
const perPage = 100;
|
||||
|
||||
// Use the GitHub API directly for manual pagination
|
||||
const github = (releaser as GitHubReleaser).github;
|
||||
if (!github) {
|
||||
// Fallback to iterator if we can't access github directly
|
||||
// Stop immediately on empty pages to avoid iterating through hundreds of empty pages
|
||||
let pageCount = 0;
|
||||
let foundAnyReleases = false;
|
||||
for await (const { data: releases } of releaser.allReleases({
|
||||
owner,
|
||||
repo,
|
||||
})) {
|
||||
pageCount++;
|
||||
if (pageCount > maxPages) {
|
||||
console.warn(
|
||||
`⚠️ Stopped pagination after ${maxPages} pages to avoid hitting GitHub's result limit`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Stop immediately on empty pages if we've found releases before
|
||||
if (releases.length === 0) {
|
||||
if (foundAnyReleases || pageCount > 1) {
|
||||
console.log(
|
||||
`Stopped pagination after encountering empty page at page ${pageCount} (to avoid iterating through empty pages)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Page 1 is empty, no releases exist
|
||||
return undefined;
|
||||
}
|
||||
foundAnyReleases = true;
|
||||
const release = releases.find((release) => release.tag_name === tag);
|
||||
if (release) {
|
||||
return release;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Manual pagination with full control
|
||||
// Stop immediately on empty pages to avoid iterating through hundreds of empty pages
|
||||
let page = 1;
|
||||
let foundAnyReleases = false;
|
||||
|
||||
while (page <= maxPages) {
|
||||
try {
|
||||
const response = await github.rest.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
per_page: perPage,
|
||||
page: page,
|
||||
});
|
||||
|
||||
const releases = response.data;
|
||||
|
||||
// If we get an empty page:
|
||||
// - If we've found releases before, stop immediately (we've hit a gap or the end)
|
||||
// - If page 1 is empty, that's fine (no releases exist), return undefined
|
||||
if (releases.length === 0) {
|
||||
if (foundAnyReleases || page > 1) {
|
||||
console.log(
|
||||
`Stopped pagination after encountering empty page at page ${page} (to avoid iterating through empty pages)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Page 1 is empty, no releases exist
|
||||
return undefined;
|
||||
}
|
||||
|
||||
foundAnyReleases = true;
|
||||
|
||||
const release = releases.find((release) => release.tag_name === tag);
|
||||
if (release) {
|
||||
return release;
|
||||
}
|
||||
|
||||
// If we got fewer results than per_page, we've reached the end
|
||||
if (releases.length < perPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} catch (error: any) {
|
||||
// If we hit the 10,000 result limit, stop immediately
|
||||
if (error.status === 422 && error.message?.includes('10000')) {
|
||||
console.warn(
|
||||
`⚠️ Stopped pagination at page ${page} due to GitHub's 10,000 result limit`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a release by tag name from all a repository's releases.
|
||||
*
|
||||
* @param releaser - The GitHub API wrapper for release operations
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The name of the repository
|
||||
* @param tag - The tag name to search for
|
||||
* @returns The release with the given tag name, or undefined if no release with that tag name is found
|
||||
*/
|
||||
export async function findTagFromReleases(
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
): Promise<Release | undefined> {
|
||||
// If tag is empty, skip direct lookup and go straight to pagination
|
||||
// (some releases may not have tags)
|
||||
if (!tag) {
|
||||
return await findTagByPagination(releaser, owner, repo, tag);
|
||||
}
|
||||
|
||||
// First try to get the release directly by tag (much more efficient than paginating)
|
||||
try {
|
||||
const { data } = await releaser.getReleaseByTag({ owner, repo, tag });
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
// If the release doesn't exist (404), return undefined
|
||||
// For other errors, fall back to pagination as a safety measure
|
||||
if (error.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
// For non-404 errors, fall back to pagination (though this should rarely happen)
|
||||
console.warn(
|
||||
`⚠️ Direct tag lookup failed (status: ${error.status}), falling back to pagination...`,
|
||||
);
|
||||
return await findTagByPagination(releaser, owner, repo, tag);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRelease(
|
||||
tag: string,
|
||||
config: Config,
|
||||
releaser: Releaser,
|
||||
owner: string,
|
||||
repo: string,
|
||||
discussion_category_name: string | undefined,
|
||||
generate_release_notes: boolean | undefined,
|
||||
maxRetries: number,
|
||||
) {
|
||||
const tag_name = tag;
|
||||
const name = config.input_name || tag;
|
||||
const body = releaseBody(config);
|
||||
const draft = config.input_draft;
|
||||
const prerelease = config.input_prerelease;
|
||||
const target_commitish = config.input_target_commitish;
|
||||
const discussion_category_name = config.input_discussion_category_name;
|
||||
const generate_release_notes = config.input_generate_release_notes;
|
||||
const make_latest = config.input_make_latest;
|
||||
|
||||
let commitMessage: string = '';
|
||||
if (target_commitish) {
|
||||
commitMessage = ` using commit "${target_commitish}"`;
|
||||
}
|
||||
console.log(`👩🏭 Creating new GitHub release for tag ${tag_name}${commitMessage}...`);
|
||||
try {
|
||||
let release = await releaser.createRelease({
|
||||
owner,
|
||||
repo,
|
||||
tag_name,
|
||||
name,
|
||||
body,
|
||||
draft,
|
||||
prerelease,
|
||||
target_commitish,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
make_latest,
|
||||
});
|
||||
return release.data;
|
||||
} catch (error) {
|
||||
// presume a race with competing matrix runs
|
||||
console.log(`⚠️ GitHub release failed with status: ${error.status}`);
|
||||
console.log(`${JSON.stringify(error.response.data)}`);
|
||||
|
||||
switch (error.status) {
|
||||
case 403:
|
||||
console.log(
|
||||
'Skip retry — your GitHub token/PAT does not have the required permission to create a release',
|
||||
);
|
||||
throw error;
|
||||
|
||||
case 404:
|
||||
console.log('Skip retry - discussion category mismatch');
|
||||
throw error;
|
||||
|
||||
case 422:
|
||||
// Check if this is a race condition with "already_exists" error
|
||||
const errorData = error.response?.data;
|
||||
if (errorData?.errors?.[0]?.code === 'already_exists') {
|
||||
console.log(
|
||||
'⚠️ Release already exists (race condition detected), retrying to find and update existing release...',
|
||||
);
|
||||
// Don't throw - allow retry to find existing release
|
||||
} else {
|
||||
console.log('Skip retry - validation failed');
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
||||
return release(config, releaser, maxRetries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const release = await releaser.createRelease({
|
||||
owner,
|
||||
repo,
|
||||
tag_name,
|
||||
name,
|
||||
body,
|
||||
draft,
|
||||
prerelease,
|
||||
target_commitish,
|
||||
discussion_category_name,
|
||||
generate_release_notes,
|
||||
make_latest,
|
||||
});
|
||||
|
||||
return release.data;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue