fix(release): fix binary CI publish and clarify release modules

Stabilize Bun compile on 1.2.19, align manifests with OSS consumers,
and split gh / webhook / mode helpers out of binary-release.
This commit is contained in:
rendianmeng
2026-07-24 10:34:39 +08:00
parent 168e2b5ccb
commit 7b949d3d3c
10 changed files with 372 additions and 246 deletions
+27 -32
View File
@@ -16,12 +16,11 @@ import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { parseArgs } from "node:util";
import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs";
import { assertChannel } from "./validate.mjs";
import { manifestFileName, normalizeModeChannel } from "./binary-options.mjs";
const BINARY_COMPILE = fileURLToPath(new URL("./binary-compile.mjs", import.meta.url));
const CLI_ENTRY = join(ROOT, "packages/cli/src/main.ts");
const DEFAULT_OUTDIR = join(ROOT, "dist-bin");
const DEFAULT_CDN = "https://github.com/modelstudioai/cli/releases";
const USAGE =
"Usage: node tools/release/lib/binary-build.mjs [--mode stable|channel] [--channel <name>] [--host] [--target <bun-target>] [--outdir <dir>]\n";
@@ -33,6 +32,16 @@ export const BINARY_TARGETS = [
{ bunTarget: "bun-windows-x64", os: "windows", arch: "x64", exe: true },
];
/** Asset basename for a matrix row: `bl-<ver>-<os>-<arch>[.exe]`. */
export function binaryAssetName(version, { os, arch, exe }) {
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
}
/** Full matrix basenames for a version (order matches BINARY_TARGETS). */
export function matrixAssetNames(version) {
return BINARY_TARGETS.map((target) => binaryAssetName(version, target));
}
function log(message = "") {
process.stdout.write(`${message}\n`);
}
@@ -41,10 +50,6 @@ function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function cdnBase() {
return (process.env.BAILIAN_CLI_CDN || DEFAULT_CDN).replace(/\/$/, "");
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
@@ -78,22 +83,12 @@ function normalizeBuildOptions({
mode = "stable",
channel = null,
}) {
if (mode !== "stable" && mode !== "channel") {
throw new Error(`--mode must be stable or channel, got: ${mode}`);
}
if (mode === "channel") {
if (!channel) throw new Error("--mode channel requires --channel <name>");
assertChannel(channel);
if (channel === "stable") {
throw new Error(`--channel cannot be "stable"; use --mode stable`);
}
}
const modeChannel = normalizeModeChannel(mode, channel);
return {
outdir: outdir ?? DEFAULT_OUTDIR,
onlyTarget,
hostOnly: Boolean(hostOnly),
mode,
channel: mode === "channel" ? channel : null,
...modeChannel,
};
}
@@ -133,16 +128,12 @@ function sha256File(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function assetFileName(version, os, arch, exe) {
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
}
function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) {
const fileName = assetFileName(version, os, arch, exe);
const fileName = binaryAssetName(version, { os, arch, exe });
const outfile = join(outdir, fileName);
log(`compile ${bunTarget} → ${fileName}`);
// Bun.build() lives in binary-compile.mjs (must run under Bun); this file stays Node.
// binary-compile.mjs shells out to `bun build --compile` (CLI); this file stays Node.
const result = spawnSync(
"bun",
[BINARY_COMPILE, "--entry", entry, "--outfile", outfile, "--target", bunTarget],
@@ -165,15 +156,19 @@ function writeChecksums(outdir, artifacts) {
writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`);
}
function writeChannelManifest(outdir, version, artifacts, mode, channel) {
const base = cdnBase();
/**
* Write latest.json (stable) or <channel>.json (channel).
* Asset entries carry file + sha256 only — no baked download URL.
* Consumers (bl update / install scripts) resolve via BAILIAN_CLI_CDN +
* `{base}/releases/{version}/{file}` (see packages/core releaseAssetUrl).
*/
function writeManifest(outdir, version, artifacts, mode, channel) {
const assets = Object.fromEntries(
artifacts.map((item) => [
`${item.os}-${item.arch}`,
{
file: item.fileName,
sha256: item.sha256,
url: `${base}/download/v${version}/${item.fileName}`,
},
]),
);
@@ -184,9 +179,9 @@ function writeChannelManifest(outdir, version, artifacts, mode, channel) {
releasedAt: new Date().toISOString(),
assets,
};
const names = mode === "stable" ? ["latest.json"] : [`${channel}.json`];
for (const name of names) writeJson(join(outdir, name), manifest);
return names;
const name = manifestFileName(mode, channel);
writeJson(join(outdir, name), manifest);
return [name];
}
function cliVersion() {
@@ -207,7 +202,7 @@ function smokeTestHostBinary(artifacts, outdir) {
}
}
/** Compile binaries into `outdir` and write checksums + channel manifest. */
/** Compile binaries into `outdir` and write checksums + manifest. */
export function buildBinaryArtifacts(rawOptions = {}) {
const options = normalizeBuildOptions(rawOptions);
const { outdir, mode, channel } = options;
@@ -223,7 +218,7 @@ export function buildBinaryArtifacts(rawOptions = {}) {
const artifacts = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY));
writeChecksums(outdir, artifacts);
const manifests = writeChannelManifest(outdir, version, artifacts, mode, channel);
const manifests = writeManifest(outdir, version, artifacts, mode, channel);
smokeTestHostBinary(artifacts, outdir);
log(`\nBuilt ${artifacts.length} binary(ies):`);
+3 -7
View File
@@ -2,9 +2,8 @@
* Single-target Bun compile helper. Must be run with Bun on PATH:
* bun tools/release/lib/binary-compile.mjs --entry <path> --outfile <path> --target <bun-target>
*
* Uses `bun build --compile` (CLI), not `Bun.build({ compile })`.
* Bun ≤1.2.19's Build API can report success without writing `compile.outfile`
* (API support landed properly around 1.2.21). CLI works on the pinned CI version.
* Uses `bun build --compile` (CLI). The Bun.build({ compile }) API on ≤1.2.19
* can exit 0 without writing outfile; CI pins 1.2.19 so we stay on the CLI.
*
* Called by binary-build.mjs (Node orchestration stays on Node).
*/
@@ -61,9 +60,6 @@ if (result.status !== 0) {
}
if (!existsSync(outfile)) {
console.error(
`bun build --compile exited 0 but outfile missing: ${outfile}\n` +
`(Bun Build API compile.outfile is unreliable on some versions; this helper uses the CLI.)`,
);
console.error(`bun build --compile exited 0 but outfile missing: ${outfile}`);
process.exit(1);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Shared mode / channel / manifest naming for binary-build and binary-release.
*/
import { assertChannel } from "./validate.mjs";
/**
* @param {string} mode
* @param {string | null | undefined} channel
* @returns {{ mode: "stable" | "channel", channel: string | null }}
*/
export function normalizeModeChannel(mode = "stable", channel = null) {
if (mode !== "stable" && mode !== "channel") {
throw new Error(`--mode must be stable or channel, got: ${mode}`);
}
if (mode === "channel") {
if (!channel) throw new Error("--mode channel requires --channel <name>");
assertChannel(channel);
if (channel === "stable") {
throw new Error(`--channel cannot be "stable"; use --mode stable`);
}
return { mode, channel };
}
return { mode: "stable", channel: null };
}
/** Manifest basename written to dist-bin / uploaded to Releases. */
export function manifestFileName(mode, channel) {
return mode === "stable" ? "latest.json" : `${channel}.json`;
}
+118 -191
View File
@@ -1,13 +1,16 @@
/**
* Publish bailian-cli binary assets to GitHub Releases via the `gh` CLI.
* Publish bailian-cli binary assets to GitHub Releases.
*
* stable: release `v<version>` (tag must already be on origin; --verify-tag)
* assets: bl-*, SHA256SUMS, latest.json
* channel: versioned prerelease `v<betaVersion>` (assets: bl-*, SHA256SUMS)
* + rolling prerelease tag `channel-<name>` holding only `<name>.json`
*
* Re-runs are idempotent: existing releases get `gh release upload --clobber`.
* Optionally POSTs BAILIAN_OSS_SYNC_WEBHOOK so an external FC can mirror to OSS.
* Same commit/day channel publishes share one `v<betaVersion>` Release (identical
* binaries); only the rolling `channel-<name>` manifest differs per channel.
*
* Re-runs are idempotent via `gh release upload --clobber` (see gh-release.mjs).
* Optionally notifies BAILIAN_OSS_SYNC_WEBHOOK (see oss-sync-webhook.mjs).
*
* Called by publish-stable.mjs / publish-channel.mjs.
* Debug:
@@ -15,90 +18,16 @@
* node tools/release/lib/binary-release.mjs --mode channel --channel beta --dry-run
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs as parseCliArgs } from "node:util";
import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs";
import { BINARY_TARGETS, buildBinaryArtifacts } from "./binary-build.mjs";
import { run, runCapture, tryRun } from "./proc.mjs";
import { assertChannel } from "./validate.mjs";
import { buildBinaryArtifacts, matrixAssetNames } from "./binary-build.mjs";
import { manifestFileName, normalizeModeChannel } from "./binary-options.mjs";
import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs";
import { notifyOssSyncWebhook } from "./oss-sync-webhook.mjs";
const REPO = process.env.GITHUB_REPOSITORY || "modelstudioai/cli";
function parseArgs(argv) {
let dir = join(ROOT, "dist-bin");
let dryRun = false;
let mode = "stable";
let channel = null;
let skipBuild = false;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--dir") dir = resolve(argv[++index]);
else if (arg === "--dry-run") dryRun = true;
else if (arg === "--mode") mode = argv[++index];
else if (arg === "--channel") channel = argv[++index];
else if (arg === "--skip-build") skipBuild = true;
else if (arg === "--help" || arg === "-h") {
process.stdout.write(
"Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel <name>] [--dir dist-bin] [--skip-build] [--dry-run]\n",
);
process.exit(0);
} else throw new Error(`Unknown argument: ${arg}`);
}
return normalizeOptions({ dir, dryRun, mode, channel, skipBuild });
}
function normalizeOptions({ dir, dryRun, mode, channel, skipBuild = false }) {
if (mode !== "stable" && mode !== "channel") {
throw new Error(`--mode must be stable or channel, got: ${mode}`);
}
if (mode === "channel") {
if (!channel) throw new Error("--mode channel requires --channel <name>");
assertChannel(channel);
if (channel === "stable") {
throw new Error(`--channel cannot be "stable"; use --mode stable`);
}
}
return {
dir: dir ?? join(ROOT, "dist-bin"),
dryRun: Boolean(dryRun),
mode,
channel: mode === "channel" ? channel : null,
skipBuild: Boolean(skipBuild),
};
}
function requiredManifestName(mode, channel) {
return mode === "stable" ? "latest.json" : `${channel}.json`;
}
function ensureGh() {
if (tryRun("gh", ["--version"]).status !== 0) {
throw new Error("gh CLI not found on PATH. Install from https://cli.github.com");
}
}
function releaseExists(tag) {
return tryRun("gh", ["release", "view", tag, "--repo", REPO]).status === 0;
}
function verifyReleaseAssets(tag, assetPaths) {
const output = runCapture("gh", [
"release",
"view",
tag,
"--repo",
REPO,
"--json",
"assets",
"--jq",
".assets[].name",
]);
const uploaded = new Set(output.split("\n").filter(Boolean));
const missing = assetPaths.map((path) => basename(path)).filter((name) => !uploaded.has(name));
if (missing.length > 0) {
throw new Error(`release ${tag} is missing assets after upload: ${missing.join(", ")}`);
}
}
const DEFAULT_DIR = join(ROOT, "dist-bin");
/** Extract the `## [<version>]` section from CHANGELOG.md, or null when absent. */
function extractChangelogSection(version) {
@@ -111,62 +40,34 @@ function extractChangelogSection(version) {
return section ? `${section}\n` : null;
}
function printPlanned(tag, assets, extraArgs) {
process.stdout.write(`[dry-run] gh release view ${tag} --repo ${REPO}\n`);
process.stdout.write(
`[dry-run] exists → gh release upload ${tag} --repo ${REPO} --clobber <assets>\n`,
);
process.stdout.write(
`[dry-run] missing → gh release create ${tag} --repo ${REPO} ${extraArgs.join(" ")} <assets>\n`,
);
for (const asset of assets) process.stdout.write(`[dry-run] asset: ${asset}\n`);
function assertFullMatrix(files, version) {
const missing = matrixAssetNames(version).filter((name) => !files.includes(name));
if (missing.length > 0) {
throw new Error(
`Incomplete binary matrix in dist-bin (missing: ${missing.join(", ")}). ` +
`Rebuild the full matrix before upload (do not use --host / partial --target for release).`,
);
}
}
/**
* Create a release with assets, or clobber-upload onto an existing one.
* options: { tag, title, prerelease, verifyTag, notes, notesFile, assets, dryRun }
*/
function upsertRelease({ tag, title, prerelease, verifyTag, notes, notesFile, assets, dryRun }) {
const createArgs = ["--title", title];
if (prerelease) createArgs.push("--prerelease", "--target", "main");
if (verifyTag) createArgs.push("--verify-tag");
if (notesFile) createArgs.push("--notes-file", notesFile);
else if (notes) createArgs.push("--notes", notes);
else createArgs.push("--generate-notes");
if (dryRun) {
printPlanned(tag, assets, createArgs);
return;
}
if (releaseExists(tag)) {
process.stdout.write(`release ${tag} exists; uploading assets with --clobber\n`);
run("gh", ["release", "upload", tag, "--repo", REPO, "--clobber", ...assets]);
} else {
run("gh", ["release", "create", tag, "--repo", REPO, ...createArgs, ...assets]);
}
verifyReleaseAssets(tag, assets);
function versionBinaryAssets(dir, version, files) {
assertFullMatrix(files, version);
const matrixNames = new Set(matrixAssetNames(version));
return files
.filter((name) => matrixNames.has(name) || name === "SHA256SUMS")
.map((name) => join(dir, name));
}
function uploadStable({ dir, version, files, dryRun }) {
const tag = `v${version}`;
const matrixNames = new Set(
BINARY_TARGETS.map(
(target) => `bl-${version}-${target.os}-${target.arch}${target.exe ? ".exe" : ""}`,
),
);
// Binaries + checksums + latest.json only. Production install.sh/ps1 are maintained
// outside this repo and served from OSS after an external FC sync.
const wanted = files.filter(
(name) => matrixNames.has(name) || name === "SHA256SUMS" || name === "latest.json",
);
const assets = wanted.map((name) => join(dir, name));
const assets = [
...versionBinaryAssets(dir, version, files),
join(dir, manifestFileName("stable", null)),
];
const section = extractChangelogSection(version);
upsertRelease({
tag,
title: tag,
tag: `v${version}`,
title: `v${version}`,
verifyTag: true,
notes: section || undefined,
assets,
@@ -175,20 +76,13 @@ function uploadStable({ dir, version, files, dryRun }) {
}
function uploadChannel({ dir, version, channel, files, dryRun }) {
const matrixNames = new Set(
BINARY_TARGETS.map(
(target) => `bl-${version}-${target.os}-${target.arch}${target.exe ? ".exe" : ""}`,
),
);
const binaries = files
.filter((name) => matrixNames.has(name) || name === "SHA256SUMS")
.map((name) => join(dir, name));
// Versioned tag is shared across channels built from the same beta version string.
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
prerelease: true,
notes: `Beta build for the \`${channel}\` channel.`,
assets: binaries,
assets: versionBinaryAssets(dir, version, files),
dryRun,
});
@@ -202,67 +96,76 @@ function uploadChannel({ dir, version, channel, files, dryRun }) {
});
}
/**
* Optional hook for an external FC that mirrors GitHub Releases → OSS.
* Set BAILIAN_OSS_SYNC_WEBHOOK to an HTTP endpoint; unset → no-op.
*/
function notifyOssSyncWebhook({ version, mode, channel, dryRun }) {
const webhook = process.env.BAILIAN_OSS_SYNC_WEBHOOK?.trim();
if (!webhook) {
process.stdout.write(
"\n[info] BAILIAN_OSS_SYNC_WEBHOOK unset; skip notifying external OSS sync FC\n",
);
/** Dry-run path when dist-bin is absent: plan tags/assets without compiling. */
function planDryRunWithoutArtifacts({ version, mode, channel }) {
const matrix = matrixAssetNames(version);
const manifestName = manifestFileName(mode, channel);
if (mode === "stable") {
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
verifyTag: true,
notes: extractChangelogSection(version) || undefined,
assets: [...matrix, "SHA256SUMS", manifestName],
dryRun: true,
});
return;
}
const tag = mode === "stable" ? `v${version}` : `v${version}`;
const body = {
repo: REPO,
mode,
channel,
version,
tag,
rollingChannelTag: mode === "channel" ? `channel-${channel}` : null,
};
if (dryRun) {
process.stdout.write(`[dry-run] POST ${webhook}\n${JSON.stringify(body, null, 2)}\n`);
return;
}
process.stdout.write(`\n==> notify OSS sync FC: ${webhook}\n`);
const result = tryRun("curl", [
"-fsS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
JSON.stringify(body),
webhook,
]);
if (result.status !== 0) {
process.stdout.write(
`[warn] OSS sync webhook failed (release already published): ${result.stderr || result.stdout}\n`,
);
return;
}
if (result.stdout) process.stdout.write(`${result.stdout}\n`);
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
prerelease: true,
notes: `Beta build for the \`${channel}\` channel.`,
assets: [...matrix, "SHA256SUMS"],
dryRun: true,
});
upsertRelease({
tag: `channel-${channel}`,
title: `channel: ${channel}`,
prerelease: true,
notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`,
assets: [manifestName],
dryRun: true,
});
}
/**
* Build (unless skipped) and upload binary artifacts to GitHub Releases.
* Build (unless skipped / dry-run) and upload binary artifacts to GitHub Releases.
* Called by publish-stable / publish-channel orchestrators.
*
* `--dry-run` never compiles; it plans gh release steps. Prebuilt `dist-bin` is
* optional (used only to list real paths when present).
*/
export function releaseBinaryArtifacts(rawOptions) {
const { dir, dryRun, mode, channel, skipBuild } = normalizeOptions(rawOptions);
export function releaseBinaryArtifacts(rawOptions = {}) {
const { mode, channel } = normalizeModeChannel(rawOptions.mode, rawOptions.channel);
const dir = rawOptions.dir ? resolve(rawOptions.dir) : DEFAULT_DIR;
const dryRun = Boolean(rawOptions.dryRun);
const skipBuild = Boolean(rawOptions.skipBuild);
const cliPkg = readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli"));
const version = cliPkg.version;
if (!skipBuild) {
if (dryRun) {
process.stdout.write(
`\n[dry-run] skipping binary build (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`,
);
} else if (!skipBuild) {
process.stdout.write(
`\n==> build binary (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`,
);
buildBinaryArtifacts({ mode, channel, outdir: dir });
}
process.stdout.write(`repo ${GITHUB_REPOSITORY}\n`);
process.stdout.write(`version ${version}\n`);
process.stdout.write(`mode ${mode}${channel ? ` channel=${channel}` : ""}\n`);
if (dryRun && !existsSync(dir)) {
process.stdout.write(`[dry-run] ${dir} missing; planning expected assets\n`);
planDryRunWithoutArtifacts({ version, mode, channel });
notifyOssSyncWebhook({ version, mode, channel, dryRun });
return { version, mode, channel, dryRun };
}
if (!existsSync(dir)) {
throw new Error(
`Missing ${dir}. Run binary-build or omit --skip-build (mode=${mode}${channel ? ` channel=${channel}` : ""}).`,
@@ -270,7 +173,7 @@ export function releaseBinaryArtifacts(rawOptions) {
}
const files = readdirSync(dir).filter((name) => !name.startsWith("."));
const manifestName = requiredManifestName(mode, channel);
const manifestName = manifestFileName(mode, channel);
if (!files.includes(manifestName)) {
throw new Error(
`Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`,
@@ -280,12 +183,12 @@ export function releaseBinaryArtifacts(rawOptions) {
throw new Error(`Missing SHA256SUMS in ${dir}`);
}
process.stdout.write(`repo ${REPO}\n`);
process.stdout.write(`version ${version}\n`);
process.stdout.write(`mode ${mode}${channel ? ` channel=${channel}` : ""}\n`);
process.stdout.write(`artifacts in ${dir}:\n`);
for (const name of files) process.stdout.write(` ${name}\n`);
// Validate matrix before touching gh, so --skip-build mistakes fail without network/CLI.
assertFullMatrix(files, version);
if (dryRun) {
process.stdout.write("\n[dry-run] skipping GitHub Release upload\n");
} else {
@@ -304,8 +207,32 @@ export function releaseBinaryArtifacts(rawOptions) {
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
const USAGE =
"Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel <name>] [--dir dist-bin] [--skip-build] [--dry-run]\n";
try {
releaseBinaryArtifacts(parseArgs(process.argv.slice(2)));
const { values } = parseCliArgs({
args: process.argv.slice(2),
options: {
dir: { type: "string" },
"dry-run": { type: "boolean", default: false },
mode: { type: "string", default: "stable" },
channel: { type: "string" },
"skip-build": { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
allowPositionals: false,
});
if (values.help) {
process.stdout.write(USAGE);
process.exit(0);
}
releaseBinaryArtifacts({
dir: values.dir ? resolve(values.dir) : undefined,
dryRun: values["dry-run"],
mode: values.mode,
channel: values.channel ?? null,
skipBuild: values["skip-build"],
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
+95
View File
@@ -0,0 +1,95 @@
/**
* Thin wrappers around `gh release` for create / clobber-upload / verify.
* Shared by binary-release (and any future publish path that needs GitHub Releases).
*/
import { basename } from "node:path";
import { run, runCapture, tryRun } from "./proc.mjs";
export const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || "modelstudioai/cli";
export function ensureGh() {
if (tryRun("gh", ["--version"]).status !== 0) {
throw new Error("gh CLI not found on PATH. Install from https://cli.github.com");
}
}
export function releaseExists(tag, repo = GITHUB_REPOSITORY) {
return tryRun("gh", ["release", "view", tag, "--repo", repo]).status === 0;
}
export function verifyReleaseAssets(tag, assetPaths, repo = GITHUB_REPOSITORY) {
const output = runCapture("gh", [
"release",
"view",
tag,
"--repo",
repo,
"--json",
"assets",
"--jq",
".assets[].name",
]);
const uploaded = new Set(output.split("\n").filter(Boolean));
const missing = assetPaths.map((path) => basename(path)).filter((name) => !uploaded.has(name));
if (missing.length > 0) {
throw new Error(`release ${tag} is missing assets after upload: ${missing.join(", ")}`);
}
}
function printPlanned(tag, assets, extraArgs, repo) {
process.stdout.write(`[dry-run] gh release view ${tag} --repo ${repo}\n`);
process.stdout.write(
`[dry-run] exists → gh release upload ${tag} --repo ${repo} --clobber <assets>\n`,
);
process.stdout.write(
`[dry-run] missing → gh release create ${tag} --repo ${repo} ${extraArgs.join(" ")} <assets>\n`,
);
for (const asset of assets) process.stdout.write(`[dry-run] asset: ${asset}\n`);
}
/**
* Create a release with assets, or clobber-upload onto an existing one.
*
* @param {{
* tag: string,
* title: string,
* prerelease?: boolean,
* verifyTag?: boolean,
* notes?: string,
* notesFile?: string,
* assets: string[],
* dryRun?: boolean,
* repo?: string,
* }} options
*/
export function upsertRelease({
tag,
title,
prerelease,
verifyTag,
notes,
notesFile,
assets,
dryRun,
repo = GITHUB_REPOSITORY,
}) {
const createArgs = ["--title", title];
if (prerelease) createArgs.push("--prerelease", "--target", "main");
if (verifyTag) createArgs.push("--verify-tag");
if (notesFile) createArgs.push("--notes-file", notesFile);
else if (notes) createArgs.push("--notes", notes);
else createArgs.push("--generate-notes");
if (dryRun) {
printPlanned(tag, assets, createArgs, repo);
return;
}
if (releaseExists(tag, repo)) {
process.stdout.write(`release ${tag} exists; uploading assets with --clobber\n`);
run("gh", ["release", "upload", tag, "--repo", repo, "--clobber", ...assets]);
} else {
run("gh", ["release", "create", tag, "--repo", repo, ...createArgs, ...assets]);
}
verifyReleaseAssets(tag, assets, repo);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Optional hook for an external FC that mirrors GitHub Releases → OSS.
* Set BAILIAN_OSS_SYNC_WEBHOOK to an HTTP endpoint; unset → no-op.
* Failure is warn-only — Release publish already succeeded.
*/
import { tryRun } from "./proc.mjs";
import { GITHUB_REPOSITORY } from "./gh-release.mjs";
/**
* @param {{
* version: string,
* mode: "stable" | "channel",
* channel: string | null,
* dryRun?: boolean,
* repo?: string,
* }} options
*/
export function notifyOssSyncWebhook({
version,
mode,
channel,
dryRun = false,
repo = GITHUB_REPOSITORY,
}) {
const webhook = process.env.BAILIAN_OSS_SYNC_WEBHOOK?.trim();
if (!webhook) {
process.stdout.write(
"\n[info] BAILIAN_OSS_SYNC_WEBHOOK unset; skip notifying external OSS sync FC\n",
);
return;
}
const tag = `v${version}`;
const body = {
repo,
mode,
channel,
version,
tag,
rollingChannelTag: mode === "channel" ? `channel-${channel}` : null,
};
if (dryRun) {
process.stdout.write(`[dry-run] POST ${webhook}\n${JSON.stringify(body, null, 2)}\n`);
return;
}
process.stdout.write(`\n==> notify OSS sync FC: ${webhook}\n`);
const result = tryRun("curl", [
"-fsS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
JSON.stringify(body),
webhook,
]);
if (result.status !== 0) {
process.stdout.write(
`[warn] OSS sync webhook failed (release already published): ${result.stderr || result.stdout}\n`,
);
return;
}
if (result.stdout) process.stdout.write(`${result.stdout}\n`);
}
+5 -2
View File
@@ -91,7 +91,7 @@ try {
// 2) binary GitHub Release — must run before finally restores package.json versions
if (skipBinary) {
log("\n[skip-binary] skipping Bun binary build/upload");
log("\n[skip-binary] skipping binary GitHub Release");
} else {
step(
`publish binary GitHub Release (mode=channel, channel=${channel}, version=${betaVersion})`,
@@ -99,9 +99,12 @@ try {
releaseBinaryArtifacts({ mode: "channel", channel, dryRun });
}
log(`\nchannel release complete: ${channel}@${betaVersion} (npm + binary)`);
const parts = ["npm"];
if (!skipBinary) parts.push("binary");
log(`\nchannel release complete: ${channel}@${betaVersion} (${parts.join(" + ")})`);
} catch (error) {
process.stderr.write(`\nrelease publish-channel failed: ${error.message}\n`);
// Use exitCode (not process.exit) so `finally` can restore package.json bumps.
process.exitCode = 1;
} finally {
restoreOriginals();
+4 -2
View File
@@ -83,13 +83,15 @@ try {
// 3) binary GitHub Release (same version; orchestrated here, not a separate release entry)
if (skipBinary) {
log("\n[skip-binary] skipping Bun binary build/upload");
log("\n[skip-binary] skipping binary GitHub Release");
} else {
step(`publish binary GitHub Release (mode=stable, version=${version})`);
releaseBinaryArtifacts({ mode: "stable", dryRun });
}
log("\nstable release complete (npm + binary).");
const parts = ["npm"];
if (!skipBinary) parts.push("binary");
log(`\nstable release complete (${parts.join(" + ")}).`);
} catch (error) {
process.stderr.write(`\nrelease publish-stable failed: ${error.message}\n`);
process.exit(1);