forked from mirrors/action-gh-release
Previously all assets were being read synchronously into memory, making the action unsuitable for releasing very large assets. Because the client library allows stream body inputs (it just forwards it to the underlying `fetch` implementation), just do it. The idea is also suggested by @enumag in https://github.com/softprops/action-gh-release/issues/353#issuecomment-1793865790. Fixes: #353 Signed-off-by: WANG Xuerui <git@xen0n.name>
399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
import { GitHub } from "@actions/github/lib/utils";
|
|
import { Config, isTag, releaseBody, alignAssetName } from "./util";
|
|
import { createReadStream, statSync, type ReadStream } from "fs";
|
|
import { getType } from "mime";
|
|
import { basename } from "path";
|
|
|
|
type GitHub = InstanceType<typeof GitHub>;
|
|
|
|
export interface ReleaseAsset {
|
|
name: string;
|
|
mime: string;
|
|
size: number;
|
|
data: ReadStream;
|
|
}
|
|
|
|
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,
|
|
data: createReadStream(path, "binary"),
|
|
};
|
|
};
|
|
|
|
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, size, mime, data: body } = 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 resp = await github.request({
|
|
method: "POST",
|
|
url: endpoint.toString(),
|
|
headers: {
|
|
"content-length": `${size}`,
|
|
"content-type": mime,
|
|
authorization: `token ${config.github_token}`,
|
|
},
|
|
data: body,
|
|
});
|
|
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)}`,
|
|
);
|
|
}
|
|
return json;
|
|
};
|
|
|
|
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 a 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) {
|
|
for await (const response of releaser.allReleases({
|
|
owner,
|
|
repo,
|
|
})) {
|
|
_release = response.data.find((release) => release.tag_name === 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,
|
|
);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|