mirror of
https://github.com/softprops/action-gh-release.git
synced 2025-06-27 22:06:03 +00:00
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
import { GitHub } from '@actions/github/lib/utils';
|
||
import { statSync } from 'fs';
|
||
import { open } from 'fs/promises';
|
||
import { lookup } from 'mime-types';
|
||
import { basename } from 'path';
|
||
import { alignAssetName, Config, isTag, releaseBody } from './util';
|
||
|
||
type GitHub = InstanceType<typeof GitHub>;
|
||
|
||
export interface ReleaseAsset {
|
||
name: string;
|
||
mime: string;
|
||
size: number;
|
||
}
|
||
|
||
export type GenerateReleaseNotesParams = Partial<
|
||
Parameters<GitHub['rest']['repos']['generateReleaseNotes']['defaults']>[0]
|
||
>;
|
||
export type CreateReleaseParams = Partial<Parameters<GitHub['rest']['repos']['createRelease']>[0]>;
|
||
export type UpdateReleaseParams = Partial<Parameters<GitHub['rest']['repos']['updateRelease']>[0]>;
|
||
|
||
export interface Release {
|
||
id: number;
|
||
upload_url: string;
|
||
html_url: string;
|
||
tag_name: string;
|
||
name: string | null;
|
||
body?: string | null | undefined;
|
||
target_commitish: string;
|
||
draft: boolean;
|
||
prerelease: boolean;
|
||
assets: Array<{ id: number; name: string }>;
|
||
}
|
||
|
||
export interface Releaser {
|
||
getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>;
|
||
|
||
createRelease(params: CreateReleaseParams): Promise<{ data: Release }>;
|
||
|
||
updateRelease(params: UpdateReleaseParams): Promise<{ data: Release }>;
|
||
|
||
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }>;
|
||
|
||
getLatestTag(params: { owner: string; repo: string }): Promise<undefined | string>;
|
||
|
||
generateReleaseBody(params: GenerateReleaseNotesParams): Promise<string>;
|
||
}
|
||
|
||
export class GitHubReleaser implements Releaser {
|
||
github: GitHub;
|
||
constructor(github: GitHub) {
|
||
this.github = github;
|
||
}
|
||
|
||
getReleaseByTag(params: {
|
||
owner: string;
|
||
repo: string;
|
||
tag: string;
|
||
}): Promise<{ data: Release }> {
|
||
return this.github.rest.repos.getReleaseByTag(params as any);
|
||
}
|
||
|
||
createRelease(params: CreateReleaseParams): Promise<{ data: Release }> {
|
||
return this.github.rest.repos.createRelease({
|
||
...params,
|
||
generate_release_notes: false,
|
||
} as any);
|
||
}
|
||
|
||
updateRelease(params: UpdateReleaseParams): Promise<{ data: Release }> {
|
||
return this.github.rest.repos.updateRelease({
|
||
...params,
|
||
generate_release_notes: false,
|
||
} as any);
|
||
}
|
||
|
||
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 as any),
|
||
);
|
||
}
|
||
|
||
async getLatestTag(params: { owner: string; repo: string }): Promise<undefined | string> {
|
||
try {
|
||
const release = await this.github.rest.repos.getLatestRelease(params as any);
|
||
|
||
if (!release?.data) {
|
||
return;
|
||
}
|
||
|
||
return release.data.tag_name;
|
||
} catch (e) {
|
||
console.error(e);
|
||
|
||
return;
|
||
}
|
||
}
|
||
|
||
async generateReleaseBody(params: GenerateReleaseNotesParams): Promise<string> {
|
||
try {
|
||
const { data } = await this.github.rest.repos.generateReleaseNotes(params as any);
|
||
|
||
if (!data.body) {
|
||
throw new Error('No release body generated');
|
||
}
|
||
|
||
return data.body;
|
||
} catch (e) {
|
||
throw e;
|
||
}
|
||
}
|
||
}
|
||
|
||
export const asset = (path: string): ReleaseAsset => {
|
||
return {
|
||
name: basename(path),
|
||
mime: mimeOrDefault(path),
|
||
size: statSync(path).size,
|
||
};
|
||
};
|
||
|
||
export const mimeOrDefault = (path: string): string => {
|
||
return lookup(path) || 'application/octet-stream';
|
||
};
|
||
|
||
export const upload = async (
|
||
config: Config,
|
||
github: GitHub,
|
||
url: string,
|
||
path: string,
|
||
currentAssets: Array<{ id: number; name: string }>,
|
||
): Promise<any> => {
|
||
const [owner, repo] = config.github_repository.split('/');
|
||
const { name, mime, size } = asset(path);
|
||
const currentAsset = currentAssets.find(
|
||
// note: GitHub renames asset filenames that have special characters, non-alphanumeric characters, and leading or trailing periods. The "List release assets" endpoint lists the renamed filenames.
|
||
// due to this renaming we need to be mindful when we compare the file name we're uploading with a name github may already have rewritten for logical comparison
|
||
// see https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset
|
||
({ name: currentName }) => currentName == alignAssetName(name),
|
||
);
|
||
if (currentAsset) {
|
||
if (config.input_overwrite_files === false) {
|
||
console.log(`Asset ${name} already exists and overwrite_files is false...`);
|
||
return null;
|
||
} else {
|
||
console.log(`♻️ Deleting previously uploaded asset ${name}...`);
|
||
await github.rest.repos.deleteReleaseAsset({
|
||
asset_id: currentAsset.id || 1,
|
||
owner,
|
||
repo,
|
||
});
|
||
}
|
||
}
|
||
console.log(`⬆️ Uploading ${name}...`);
|
||
const endpoint = new URL(url);
|
||
endpoint.searchParams.append('name', name);
|
||
const fh = await open(path);
|
||
try {
|
||
const resp = await github.request({
|
||
method: 'POST',
|
||
url: endpoint.toString(),
|
||
headers: {
|
||
'content-length': `${size}`,
|
||
'content-type': mime,
|
||
authorization: `token ${config.github_token}`,
|
||
},
|
||
data: fh.readableWebStream({ type: 'bytes' }),
|
||
});
|
||
const json = resp.data;
|
||
if (resp.status !== 201) {
|
||
throw new Error(
|
||
`Failed to upload release asset ${name}. received status code ${
|
||
resp.status
|
||
}\n${json.message}\n${JSON.stringify(json.errors)}`,
|
||
);
|
||
}
|
||
console.log(`✅ Uploaded ${name}`);
|
||
return json;
|
||
} finally {
|
||
await fh.close();
|
||
}
|
||
};
|
||
|
||
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 previous_tag = config.input_previous_tag;
|
||
const discussion_category_name = config.input_discussion_category_name;
|
||
const generate_release_notes = config.input_generate_release_notes;
|
||
|
||
const latestTag: string | undefined = !previous_tag
|
||
? await releaser.getLatestTag({
|
||
owner,
|
||
repo,
|
||
})
|
||
: undefined;
|
||
|
||
if (latestTag) {
|
||
console.log(`🏷️ Latest tag related to a release is ${latestTag}`);
|
||
} else if (previous_tag) {
|
||
console.log(`🏷️ Previous tag is ${previous_tag}`);
|
||
}
|
||
|
||
const tag_name = tag;
|
||
|
||
let body: string = generate_release_notes
|
||
? await releaser.generateReleaseBody({
|
||
owner,
|
||
repo,
|
||
tag_name,
|
||
previous_tag_name: previous_tag || latestTag,
|
||
} as GenerateReleaseNotesParams)
|
||
: '';
|
||
|
||
if ((generate_release_notes && previous_tag) || latestTag) {
|
||
console.log(`Will generate release notes using ${previous_tag || latestTag} as previous tag`);
|
||
}
|
||
|
||
body = body ? `${body}\n` : '';
|
||
|
||
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 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 || '';
|
||
|
||
if (config.input_append_body && workflowBody && existingReleaseBody) {
|
||
console.log('➕ Appending existing release body');
|
||
body = body + existingReleaseBody + '\n' + workflowBody;
|
||
} else {
|
||
console.log(`➕ Using ${workflowBody ? 'workflow body' : 'existing release body'}`);
|
||
body = 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,
|
||
make_latest,
|
||
} as UpdateReleaseParams);
|
||
return release.data;
|
||
} catch (error) {
|
||
if (error.status === 404) {
|
||
const tag_name = tag;
|
||
const name = config.input_name || tag;
|
||
const workflowBody = releaseBody(config) || '';
|
||
|
||
if (config.input_append_body && workflowBody) {
|
||
console.log('➕ Appending existing release body');
|
||
body = body + workflowBody;
|
||
}
|
||
|
||
const draft = config.input_draft;
|
||
const prerelease = config.input_prerelease;
|
||
const target_commitish = config.input_target_commitish;
|
||
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,
|
||
make_latest,
|
||
} as CreateReleaseParams);
|
||
return release.data;
|
||
} catch (error) {
|
||
// presume a race with competing metrix runs
|
||
console.log(
|
||
`⚠️ GitHub release failed with status: ${
|
||
error.status
|
||
}\n${JSON.stringify(error.response.data.errors)}\nretrying... (${
|
||
maxRetries - 1
|
||
} retries remaining)`,
|
||
);
|
||
return release(config, releaser, maxRetries - 1);
|
||
}
|
||
} else {
|
||
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 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 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,
|
||
make_latest,
|
||
} as CreateReleaseParams);
|
||
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:
|
||
console.log('Skip retry - validation failed');
|
||
throw error;
|
||
}
|
||
|
||
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
||
return release(config, releaser, maxRetries - 1);
|
||
}
|
||
}
|