mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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):`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user