mirror of
https://github.com/softprops/action-gh-release.git
synced 2025-05-10 02:44:19 +00:00
add tests for updated functionality to break when we find a release. the logic has been extracted into its own function, to make testing simpler by avoiding over mocking/stubbing of network calls that would create or update a release. the tests that were added use jest's describe/it blocks, but use node's assert function to align with other tests. there isn't any prior art for mocking function calls in the codebase, so for now we use simple promises in "mock" objects that adhere to the Releaser interface
428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
import { GitHub } from "@actions/github/lib/utils";
|
|
import { Config, isTag, releaseBody, alignAssetName } from "./util";
|
|
import { statSync } from "fs";
|
|
import { open } from "fs/promises";
|
|
import { getType } from "mime";
|
|
import { basename } from "path";
|
|
|
|
type GitHub = InstanceType<typeof GitHub>;
|
|
|
|
export interface ReleaseAsset {
|
|
name: string;
|
|
mime: string;
|
|
size: number;
|
|
}
|
|
|
|
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: {
|
|
owner: string;
|
|
repo: string;
|
|
tag_name: string;
|
|
name: string;
|
|
body: string | undefined;
|
|
draft: boolean | undefined;
|
|
prerelease: boolean | undefined;
|
|
target_commitish: string | undefined;
|
|
discussion_category_name: string | undefined;
|
|
generate_release_notes: boolean | undefined;
|
|
make_latest: "true" | "false" | "legacy" | undefined;
|
|
}): Promise<{ data: Release }>;
|
|
|
|
updateRelease(params: {
|
|
owner: string;
|
|
repo: string;
|
|
release_id: number;
|
|
tag_name: string;
|
|
target_commitish: string;
|
|
name: string;
|
|
body: string | undefined;
|
|
draft: boolean | undefined;
|
|
prerelease: boolean | undefined;
|
|
discussion_category_name: string | undefined;
|
|
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 {
|
|
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);
|
|
}
|
|
|
|
createRelease(params: {
|
|
owner: string;
|
|
repo: string;
|
|
tag_name: string;
|
|
name: string;
|
|
body: string | undefined;
|
|
draft: boolean | undefined;
|
|
prerelease: boolean | undefined;
|
|
target_commitish: string | undefined;
|
|
discussion_category_name: string | undefined;
|
|
generate_release_notes: boolean | undefined;
|
|
make_latest: "true" | "false" | "legacy" | undefined;
|
|
}): Promise<{ data: Release }> {
|
|
if (
|
|
typeof params.make_latest === "string" &&
|
|
!["true", "false", "legacy"].includes(params.make_latest)
|
|
) {
|
|
params.make_latest = undefined;
|
|
}
|
|
|
|
return this.github.rest.repos.createRelease(params);
|
|
}
|
|
|
|
updateRelease(params: {
|
|
owner: string;
|
|
repo: string;
|
|
release_id: number;
|
|
tag_name: string;
|
|
target_commitish: string;
|
|
name: string;
|
|
body: string | undefined;
|
|
draft: boolean | undefined;
|
|
prerelease: boolean | undefined;
|
|
discussion_category_name: string | undefined;
|
|
generate_release_notes: boolean | undefined;
|
|
make_latest: "true" | "false" | "legacy" | undefined;
|
|
}): Promise<{ data: Release }> {
|
|
if (
|
|
typeof params.make_latest === "string" &&
|
|
!["true", "false", "legacy"].includes(params.make_latest)
|
|
) {
|
|
params.make_latest = 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 => {
|
|
return {
|
|
name: basename(path),
|
|
mime: mimeOrDefault(path),
|
|
size: statSync(path).size,
|
|
};
|
|
};
|
|
|
|
export const mimeOrDefault = (path: string): string => {
|
|
return getType(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) {
|
|
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 discussion_category_name = config.input_discussion_category_name;
|
|
const generate_release_notes = config.input_generate_release_notes;
|
|
try {
|
|
// you can't get an existing draft by tag
|
|
// so we must find one in the list of all releases
|
|
let _release: Release | undefined = undefined;
|
|
if (config.input_draft) {
|
|
_release = await findTagFromReleases(releaser, owner, repo, tag);
|
|
} else {
|
|
_release = (
|
|
await releaser.getReleaseByTag({
|
|
owner,
|
|
repo,
|
|
tag,
|
|
})
|
|
).data;
|
|
}
|
|
if (_release === null || _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,
|
|
) {
|
|
let _release: Release | undefined;
|
|
for await (const response of releaser.allReleases({
|
|
owner,
|
|
repo,
|
|
})) {
|
|
_release = response.data.find((release) => release.tag_name === tag);
|
|
// detect if we found a release - note that a draft release tag may be an empty string
|
|
if (typeof _release !== "undefined") {
|
|
break;
|
|
}
|
|
}
|
|
return _release;
|
|
}
|
|
|
|
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,
|
|
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:
|
|
console.log("Skip retry - validation failed");
|
|
throw error;
|
|
}
|
|
|
|
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
|
|
return release(config, releaser, maxRetries - 1);
|
|
}
|
|
}
|