Signed-off-by: Rui Chen <rui@chenrui.dev>
This commit is contained in:
Rui Chen 2025-06-10 20:37:24 -04:00
commit a6b02d29ce
No known key found for this signature in database
GPG key ID: 6577287BDCA70840
17 changed files with 748 additions and 135 deletions

View file

@ -1,8 +1,9 @@
import { GitHub } from "@actions/github/lib/utils";
import { Config, isTag, releaseBody } from "./util";
import { statSync, readFileSync } from "fs";
import mime from "mime";
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>;
@ -10,12 +11,17 @@ export interface ReleaseAsset {
name: string;
mime: string;
size: number;
data: Buffer;
}
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 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;
@ -51,9 +57,7 @@ export interface Releaser {
repo: string;
}): Promise<undefined | string>;
generateReleaseBody(
params: GenerateReleaseNotesParams
): Promise<string>;
generateReleaseBody(params: GenerateReleaseNotesParams): Promise<string>;
}
export class GitHubReleaser implements Releaser {
@ -73,7 +77,7 @@ export class GitHubReleaser implements Releaser {
createRelease(params: CreateReleaseParams): Promise<{ data: Release }> {
return this.github.rest.repos.createRelease({
...params,
generate_release_notes: false
generate_release_notes: false,
} as any);
}
@ -99,7 +103,9 @@ export class GitHubReleaser implements Releaser {
repo: string;
}): Promise<undefined | string> {
try {
const release = await this.github.rest.repos.getLatestRelease(params as any);
const release = await this.github.rest.repos.getLatestRelease(
params as any
);
if (!release?.data) {
return;
@ -113,7 +119,9 @@ export class GitHubReleaser implements Releaser {
}
}
async generateReleaseBody(params: GenerateReleaseNotesParams): Promise<string> {
async generateReleaseBody(
params: GenerateReleaseNotesParams
): Promise<string> {
try {
const { data } = await this.github.rest.repos.generateReleaseNotes(
params as any
@ -135,12 +143,11 @@ export const asset = (path: string): ReleaseAsset => {
name: basename(path),
mime: mimeOrDefault(path),
size: statSync(path).size,
data: readFileSync(path),
};
};
export const mimeOrDefault = (path: string): string => {
return mime.getType(path) || "application/octet-stream";
return lookup(path) || "application/octet-stream";
};
export const upload = async (
@ -151,9 +158,12 @@ export const upload = async (
currentAssets: Array<{ id: number; name: string }>
): Promise<any> => {
const [owner, repo] = config.github_repository.split("/");
const { name, size, mime, data: body } = asset(path);
const { name, mime, size } = asset(path);
const currentAsset = currentAssets.find(
({ name: currentName }) => currentName == name
// 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}...`);
@ -166,25 +176,31 @@ export const upload = async (
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)}`
);
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();
}
return json;
};
export const release = async (
@ -243,47 +259,54 @@ export const release = async (
body = body ? `${body}\n` : "";
try {
// you can't get a an existing draft by tag
// so we must find one in the list of all releases
if (config.input_draft) {
for await (const response of releaser.allReleases({
owner,
repo,
})) {
let release = response.data.find((release) => release.tag_name === tag);
if (release) {
return release;
}
}
}
let existingRelease = await releaser.getReleaseByTag({
const _release: Release | undefined = await findTagFromReleases(
releaser,
owner,
repo,
tag,
});
tag
);
const release_id = existingRelease.data.id;
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.data.target_commitish
config.input_target_commitish !== existingRelease.target_commitish
) {
console.log(
`Updating commit from "${existingRelease.data.target_commitish}" to "${config.input_target_commitish}"`
`Updating commit from "${existingRelease.target_commitish}" to "${config.input_target_commitish}"`
);
target_commitish = config.input_target_commitish;
} else {
target_commitish = existingRelease.data.target_commitish;
target_commitish = existingRelease.target_commitish;
}
const name = config.input_name || existingRelease.data.name || tag;
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.data.body || "";
const existingReleaseBody = existingRelease.body || "";
let body: string;
if (config.input_append_body && workflowBody && existingReleaseBody) {
console.log(" Appending existing release body");
body = body + existingReleaseBody + "\n" + workflowBody;
@ -297,11 +320,11 @@ export const release = async (
const draft =
config.input_draft !== undefined
? config.input_draft
: existingRelease.data.draft;
: existingRelease.draft;
const prerelease =
config.input_prerelease !== undefined
? config.input_prerelease
: existingRelease.data.prerelease;
: existingRelease.prerelease;
const make_latest = config.input_make_latest!;
@ -372,5 +395,108 @@ export const release = async (
);
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,
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);
}
}