mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(release): CI-driven publish pipeline via GitHub Actions + npm OIDC
Replace tools/release.mjs with two workflow_dispatch flows: - stable: production environment gate (Required Reviewers) + lightweight git tag. Trusted Publishing (OIDC) removes the need for an npm token. - channel beta: disposable 0.0.0-beta-<sha>-<date> versions on the corresponding dist-tag, no tag, no commit. Any collaborator can dispatch without npm credentials. Pack-time scans via publint, attw, gitleaks; weekly Dependabot for npm + actions.
This commit is contained in:
@@ -1,370 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { dirname, join, relative, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { spawnSync } from "child_process";
|
||||
import { createInterface } from "readline/promises";
|
||||
import { stdin as input, stdout as output } from "process";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PUBLIC_REGISTRY = "https://registry.npmjs.org/";
|
||||
const PACKAGES = [
|
||||
{ key: "core", dir: "packages/core", name: "bailian-cli-core" },
|
||||
{ key: "cli", dir: "packages/cli", name: "bailian-cli" },
|
||||
];
|
||||
|
||||
function log(message = "") {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function step(message) {
|
||||
log(`\n==> ${message}`);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? ROOT,
|
||||
stdio: options.stdio ?? "inherit",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const detail = result.stderr?.trim() || result.stdout?.trim();
|
||||
fail(`${command} ${args.join(" ")} failed${detail ? `\n${detail}` : ""}`);
|
||||
}
|
||||
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
function packageJson(pkg) {
|
||||
return readJson(join(ROOT, pkg.dir, "package.json"));
|
||||
}
|
||||
|
||||
function tarballName(name, version) {
|
||||
return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map semver version to npm dist-tag.
|
||||
* 1.0.0 → latest
|
||||
* 1.0.0-beta.0 → beta
|
||||
* 1.0.0-rc.1 → rc
|
||||
* 1.0.0-alpha.2 → alpha
|
||||
* 1.0.0-next.5 → next
|
||||
* Avoids accidentally tagging prereleases as latest.
|
||||
*/
|
||||
function deriveDistTag(version) {
|
||||
const m = /-([a-z]+)\b/i.exec(version);
|
||||
return m ? m[1].toLowerCase() : "latest";
|
||||
}
|
||||
|
||||
function walkFiles(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walkFiles(path));
|
||||
else if (entry.isFile()) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function assertPublishConfig(pkg, json) {
|
||||
const registry = json.publishConfig?.registry;
|
||||
if (registry !== PUBLIC_REGISTRY) {
|
||||
fail(`${pkg.name} publishConfig.registry must be ${PUBLIC_REGISTRY}, got ${registry}`);
|
||||
}
|
||||
}
|
||||
|
||||
function packPackage(pkg, tempDir) {
|
||||
const json = packageJson(pkg);
|
||||
const name = json.name;
|
||||
const version = json.version;
|
||||
|
||||
run("pnpm", ["--filter", name, "pack", "--pack-destination", tempDir]);
|
||||
|
||||
const tarball = join(tempDir, tarballName(name, version));
|
||||
statSync(tarball);
|
||||
return { pkg, json, tarball };
|
||||
}
|
||||
|
||||
function extractTarball(tarball, tempDir, label) {
|
||||
const extractDir = join(tempDir, `extract-${label}`);
|
||||
run("tar", ["-xzf", tarball, "-C", tempDir], { stdio: "pipe" });
|
||||
const packageDir = join(tempDir, "package");
|
||||
renameSync(packageDir, extractDir);
|
||||
return extractDir;
|
||||
}
|
||||
|
||||
function looksText(buffer) {
|
||||
if (buffer.includes(0)) return false;
|
||||
const sample = buffer.subarray(0, 4096).toString("utf-8");
|
||||
return !sample.includes("\uFFFD");
|
||||
}
|
||||
|
||||
function scanPackageContents(label, extractDir) {
|
||||
const denyPathPatterns = [
|
||||
/(^|\/)\.env($|\.)/,
|
||||
/(^|\/)\.npmrc$/,
|
||||
/(^|\/)\.yarnrc$/,
|
||||
/(^|\/)\.pnpmfile\.cjs$/,
|
||||
/(^|\/)\.DS_Store$/,
|
||||
/(^|\/)npm-debug\.log$/,
|
||||
/(^|\/)yarn-error\.log$/,
|
||||
/\.(map|pem|key|crt|p12|pfx|log)$/i,
|
||||
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
|
||||
];
|
||||
const secretPatterns = [
|
||||
{ name: "DashScope API key", re: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{18,}\b/g },
|
||||
{ name: "Alibaba Cloud access key id", re: /\bLTAI[A-Za-z0-9]{12,}\b/g },
|
||||
{
|
||||
name: "access key secret assignment",
|
||||
re: /\b(?:access[_-]?key[_-]?secret|aliyun[_-]?access[_-]?key[_-]?secret|alibaba[_-]?cloud[_-]?access[_-]?key[_-]?secret)\b\s*[:=]\s*["'][^"']{12,}["']/gi,
|
||||
},
|
||||
];
|
||||
|
||||
const files = walkFiles(extractDir);
|
||||
for (const file of files) {
|
||||
const rel = relative(extractDir, file).replaceAll("\\", "/");
|
||||
if (denyPathPatterns.some((pattern) => pattern.test(rel))) {
|
||||
fail(`${label} contains blocked file: ${rel}`);
|
||||
}
|
||||
|
||||
const size = statSync(file).size;
|
||||
if (size > 2 * 1024 * 1024) continue;
|
||||
|
||||
const buffer = readFileSync(file);
|
||||
if (!looksText(buffer)) continue;
|
||||
|
||||
const text = buffer.toString("utf-8");
|
||||
for (const pattern of secretPatterns) {
|
||||
pattern.re.lastIndex = 0;
|
||||
if (pattern.re.test(text)) {
|
||||
fail(`${label} may contain ${pattern.name}: ${rel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertCliPackage(cliExtractDir, coreJson) {
|
||||
const json = readJson(join(cliExtractDir, "package.json"));
|
||||
const deps = json.dependencies ?? {};
|
||||
|
||||
if (deps["bailian-cli-core"] !== coreJson.version) {
|
||||
fail(`CLI tarball must depend on bailian-cli-core@${coreJson.version}.`);
|
||||
}
|
||||
|
||||
if (JSON.stringify(json).includes("workspace:")) {
|
||||
fail("CLI tarball package.json still contains workspace: dependency.");
|
||||
}
|
||||
|
||||
const binPath = json.bin?.bl;
|
||||
if (binPath !== "dist/bailian.mjs") {
|
||||
fail(`CLI bin.bl must be dist/bailian.mjs, got ${binPath}`);
|
||||
}
|
||||
|
||||
const bin = readFileSync(join(cliExtractDir, binPath), "utf-8");
|
||||
if (!bin.startsWith("#!/usr/bin/env node\n")) {
|
||||
fail("CLI bin is missing #!/usr/bin/env node shebang.");
|
||||
}
|
||||
|
||||
if (!bin.includes('from"bailian-cli-core"') && !bin.includes('from "bailian-cli-core"')) {
|
||||
fail("CLI bundle does not appear to import bailian-cli-core as an external package.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertCorePackage(coreExtractDir) {
|
||||
for (const file of ["dist/index.mjs", "dist/index.d.mts"]) {
|
||||
statSync(join(coreExtractDir, file));
|
||||
}
|
||||
}
|
||||
|
||||
function assertReadmeSync() {
|
||||
step("Checking README sync between root and packages/cli");
|
||||
for (const file of ["README.md", "README_CN.md"]) {
|
||||
const rootPath = join(ROOT, file);
|
||||
const cliPath = join(ROOT, "packages/cli", file);
|
||||
const rootBuf = readFileSync(rootPath);
|
||||
const cliBuf = readFileSync(cliPath);
|
||||
if (!rootBuf.equals(cliBuf)) {
|
||||
fail(
|
||||
`${file} differs between root and packages/cli. ` +
|
||||
`Sync them manually (e.g. \`cp ${file} packages/cli/${file}\`).`,
|
||||
);
|
||||
}
|
||||
log(`${file}: in sync`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePackages() {
|
||||
const jsonByKey = new Map();
|
||||
|
||||
assertReadmeSync();
|
||||
|
||||
step("Checking package metadata");
|
||||
for (const pkg of PACKAGES) {
|
||||
const json = packageJson(pkg);
|
||||
if (json.name !== pkg.name) fail(`${pkg.dir} name must be ${pkg.name}`);
|
||||
assertPublishConfig(pkg, json);
|
||||
jsonByKey.set(pkg.key, json);
|
||||
log(`${json.name}@${json.version}`);
|
||||
}
|
||||
|
||||
const coreJson = jsonByKey.get("core");
|
||||
const cliJson = jsonByKey.get("cli");
|
||||
if (cliJson.version !== coreJson.version) {
|
||||
fail(`CLI and core versions should match, got ${cliJson.version} and ${coreJson.version}.`);
|
||||
}
|
||||
|
||||
const cliCoreDep = cliJson.dependencies?.["bailian-cli-core"];
|
||||
if (cliCoreDep !== "workspace:*") {
|
||||
fail(`CLI source dependency should be "bailian-cli-core": "workspace:*", got ${cliCoreDep}`);
|
||||
}
|
||||
|
||||
return { coreJson, cliJson };
|
||||
}
|
||||
|
||||
function packAndScan(coreJson) {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-"));
|
||||
try {
|
||||
step("Packing and scanning npm tarballs");
|
||||
|
||||
const packed = PACKAGES.map((pkg) => packPackage(pkg, tempDir));
|
||||
const extracted = new Map();
|
||||
|
||||
for (const item of packed) {
|
||||
const extractDir = extractTarball(item.tarball, tempDir, item.pkg.key);
|
||||
extracted.set(item.pkg.key, extractDir);
|
||||
scanPackageContents(item.json.name, extractDir);
|
||||
log(`${item.json.name}: ok`);
|
||||
}
|
||||
|
||||
assertCorePackage(extracted.get("core"));
|
||||
assertCliPackage(extracted.get("cli"), coreJson);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildPackages() {
|
||||
run("pnpm", ["--filter", "bailian-cli-core", "run", "build"]);
|
||||
run("pnpm", ["--filter", "bailian-cli", "run", "build"]);
|
||||
}
|
||||
|
||||
async function releaseCheck() {
|
||||
const { coreJson } = validatePackages();
|
||||
|
||||
step("Installing dependencies with frozen lockfile");
|
||||
run("pnpm", ["install", "--frozen-lockfile"]);
|
||||
|
||||
step("Building packages");
|
||||
buildPackages();
|
||||
|
||||
step("Running format, lint, and type checks");
|
||||
run("pnpm", ["run", "check"]);
|
||||
|
||||
packAndScan(coreJson);
|
||||
|
||||
log("\nRelease check passed.");
|
||||
}
|
||||
|
||||
function npmWhoami() {
|
||||
const result = spawnSync("npm", ["whoami", `--registry=${PUBLIC_REGISTRY}`], {
|
||||
cwd: ROOT,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) return null;
|
||||
return result.stdout.trim() || null;
|
||||
}
|
||||
|
||||
function ensureNpmLogin() {
|
||||
step("Checking npm login");
|
||||
|
||||
let user = npmWhoami();
|
||||
if (user) {
|
||||
log(`Logged in as ${user}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Not logged in to ${PUBLIC_REGISTRY}. Launching npm login...`);
|
||||
const login = spawnSync("npm", ["login", `--registry=${PUBLIC_REGISTRY}`], {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (login.status !== 0) fail("npm login failed.");
|
||||
|
||||
user = npmWhoami();
|
||||
if (!user) fail("npm login required before publishing.");
|
||||
log(`Logged in as ${user}`);
|
||||
}
|
||||
|
||||
async function confirmPublish(coreJson, cliJson) {
|
||||
log("");
|
||||
log(`About to publish ${coreJson.name}@${coreJson.version}`);
|
||||
log(`Then publish ${cliJson.name}@${cliJson.version}`);
|
||||
log(`Registry: ${PUBLIC_REGISTRY}`);
|
||||
|
||||
const rl = createInterface({ input, output });
|
||||
const answer = await rl.question("\nType 'publish' to continue: ");
|
||||
rl.close();
|
||||
|
||||
if (answer !== "publish") fail("Publish aborted.");
|
||||
}
|
||||
|
||||
async function releasePublish() {
|
||||
const { coreJson, cliJson } = validatePackages();
|
||||
|
||||
step("Building packages");
|
||||
buildPackages();
|
||||
|
||||
packAndScan(coreJson);
|
||||
|
||||
await confirmPublish(coreJson, cliJson);
|
||||
|
||||
ensureNpmLogin();
|
||||
|
||||
// Derive dist-tag from version: 1.0.0 → latest, 1.0.0-beta.0 → beta, 1.0.0-rc.1 → rc
|
||||
const distTag = deriveDistTag(coreJson.version);
|
||||
log(`Publishing under dist-tag: ${distTag}`);
|
||||
|
||||
step(`Publishing ${coreJson.name}`);
|
||||
run("pnpm", [
|
||||
"--filter",
|
||||
coreJson.name,
|
||||
"publish",
|
||||
`--registry=${PUBLIC_REGISTRY}`,
|
||||
`--tag=${distTag}`,
|
||||
"--no-git-checks",
|
||||
]);
|
||||
|
||||
step(`Publishing ${cliJson.name}`);
|
||||
run("pnpm", [
|
||||
"--filter",
|
||||
cliJson.name,
|
||||
"publish",
|
||||
`--registry=${PUBLIC_REGISTRY}`,
|
||||
`--tag=${distTag}`,
|
||||
"--no-git-checks",
|
||||
]);
|
||||
|
||||
log("\nPublish complete.");
|
||||
}
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
try {
|
||||
if (command === "check") await releaseCheck();
|
||||
else if (command === "publish") await releasePublish();
|
||||
else fail("Usage: node tools/release.mjs <check|publish>");
|
||||
} catch (error) {
|
||||
process.stderr.write(`\nRelease failed: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { packAndScan } from "./lib/pack-scan.mjs";
|
||||
import { run } from "./lib/proc.mjs";
|
||||
import { assertReadmeSync, loadAndValidatePackages } from "./lib/validate.mjs";
|
||||
|
||||
function log(msg = "") {
|
||||
process.stdout.write(`${msg}\n`);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
log(`\n==> ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-validation pipeline. Reusable from publish-stable / publish-channel.
|
||||
* Returns { coreJson, cliJson } for callers that need the parsed package.jsons.
|
||||
*/
|
||||
export async function runCheck() {
|
||||
step("pnpm install --frozen-lockfile");
|
||||
run("pnpm", ["install", "--frozen-lockfile"]);
|
||||
|
||||
step("metadata: README sync, version consistency, workspace:* dep");
|
||||
assertReadmeSync();
|
||||
const { coreJson, cliJson } = loadAndValidatePackages();
|
||||
log(`bailian-cli-core@${coreJson.version}`);
|
||||
log(`bailian-cli@${cliJson.version}`);
|
||||
|
||||
step("build bailian-cli-core");
|
||||
run("pnpm", ["--filter", "bailian-cli-core", "run", "build"]);
|
||||
|
||||
step("build bailian-cli");
|
||||
run("pnpm", ["--filter", "bailian-cli", "run", "build"]);
|
||||
|
||||
step("pack + scan (publint, attw, gitleaks)");
|
||||
packAndScan({ log });
|
||||
|
||||
log("\nrelease check passed.");
|
||||
return { coreJson, cliJson };
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] === fileURLToPath(import.meta.url);
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
await runCheck();
|
||||
} catch (error) {
|
||||
process.stderr.write(`\nrelease check failed: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { runCapture, tryRun, run } from "./proc.mjs";
|
||||
|
||||
export function headSha7() {
|
||||
return runCapture("git", ["rev-parse", "--short=7", "HEAD"]);
|
||||
}
|
||||
|
||||
export function currentBranch() {
|
||||
// GitHub Actions checks out a detached HEAD; `git rev-parse --abbrev-ref HEAD`
|
||||
// returns "HEAD" there, so prefer GITHUB_REF_NAME when running in CI.
|
||||
if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME;
|
||||
return runCapture("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
}
|
||||
|
||||
export function isWorkingTreeClean() {
|
||||
return runCapture("git", ["status", "--porcelain"]) === "";
|
||||
}
|
||||
|
||||
export function tagExists(tag, { remote = "origin" } = {}) {
|
||||
// local
|
||||
if (tryRun("git", ["rev-parse", "--verify", `refs/tags/${tag}`]).status === 0) return true;
|
||||
// remote — actions/checkout fetch-depth:0 usually fetches tags, but ls-remote
|
||||
// is the only authoritative source.
|
||||
const r = tryRun("git", ["ls-remote", "--tags", remote, `refs/tags/${tag}`]);
|
||||
return r.status === 0 && r.stdout !== "";
|
||||
}
|
||||
|
||||
export function createTag(tag) {
|
||||
// Lightweight tag: just a ref pointing at HEAD. No tagger identity needed,
|
||||
// so the workflow doesn't need `git config user.name/email`.
|
||||
run("git", ["tag", tag]);
|
||||
}
|
||||
|
||||
export function pushTag(tag, remote = "origin") {
|
||||
run("git", ["push", remote, tag]);
|
||||
}
|
||||
|
||||
export function utcDateStamp() {
|
||||
const iso = runCapture("date", ["-u", "+%Y%m%d"]);
|
||||
return iso;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { statSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { ROOT, tarballFileName } from "./packages.mjs";
|
||||
import { run, tryRun } from "./proc.mjs";
|
||||
|
||||
/** Returns true if the exact name@version already exists on the registry. */
|
||||
export function npmViewExists(name, version) {
|
||||
const result = tryRun("npm", ["view", `${name}@${version}`, "version"]);
|
||||
if (result.status === 0 && result.stdout.includes(version)) return true;
|
||||
// npm view returns non-zero ("E404") for unknown versions; treat as "does not exist"
|
||||
if (result.stderr.includes("E404") || result.stderr.includes("404")) return false;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`npm view ${name}@${version} failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pnpmPack(pkg, destDir, json) {
|
||||
run("pnpm", ["--filter", pkg.name, "pack", "--pack-destination", destDir], { cwd: ROOT });
|
||||
const tarball = join(destDir, tarballFileName(pkg.name, json.version));
|
||||
statSync(tarball);
|
||||
return tarball;
|
||||
}
|
||||
|
||||
export function pnpmPublish(pkg, { tag, provenance = true, dryRun = false } = {}) {
|
||||
const args = ["--filter", pkg.name, "publish", "--no-git-checks"];
|
||||
if (tag) args.push("--tag", tag);
|
||||
// --provenance requires OIDC; suppress in dry-run so local devs can test
|
||||
// the pipeline without GitHub Actions credentials.
|
||||
if (provenance && !dryRun) args.push("--provenance");
|
||||
if (dryRun) args.push("--dry-run");
|
||||
run("pnpm", args, { cwd: ROOT });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mkdtempSync, renameSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
import { PACKAGES, readPackageJson } from "./packages.mjs";
|
||||
import { pnpmPack } from "./npm.mjs";
|
||||
import { run } from "./proc.mjs";
|
||||
|
||||
function extractTarball(tarball, tempDir, key) {
|
||||
run("tar", ["-xzf", tarball, "-C", tempDir], { stdio: "pipe" });
|
||||
const extractDir = join(tempDir, `extract-${key}`);
|
||||
renameSync(join(tempDir, "package"), extractDir);
|
||||
return extractDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* pnpm pack each package, then run publint / attw / gitleaks on the tarball
|
||||
* or the extracted directory. attw only runs on packages that declare types.
|
||||
*/
|
||||
export function packAndScan({ log }) {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-"));
|
||||
try {
|
||||
for (const pkg of PACKAGES) {
|
||||
const json = readPackageJson(pkg);
|
||||
log(`packing ${pkg.name}@${json.version}`);
|
||||
const tarball = pnpmPack(pkg, tempDir, json);
|
||||
const extractDir = extractTarball(tarball, tempDir, pkg.key);
|
||||
|
||||
log(`publint ${pkg.name}`);
|
||||
run("npx", ["--yes", "publint", extractDir]);
|
||||
|
||||
if (json.types) {
|
||||
log(`attw ${pkg.name}`);
|
||||
run("npx", ["--yes", "@arethetypeswrong/cli", "--pack", extractDir]);
|
||||
}
|
||||
|
||||
log(`gitleaks ${pkg.name}`);
|
||||
run("gitleaks", ["detect", "--source", extractDir, "--no-git", "--redact"]);
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
export const PACKAGES = [
|
||||
{ key: "core", dir: "packages/core", name: "bailian-cli-core" },
|
||||
{ key: "cli", dir: "packages/cli", name: "bailian-cli" },
|
||||
];
|
||||
|
||||
export function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
export function packageJsonPath(pkg) {
|
||||
return join(ROOT, pkg.dir, "package.json");
|
||||
}
|
||||
|
||||
export function readPackageJson(pkg) {
|
||||
return readJson(packageJsonPath(pkg));
|
||||
}
|
||||
|
||||
export function writePackageJson(pkg, json) {
|
||||
writeFileSync(packageJsonPath(pkg), `${JSON.stringify(json, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function tarballFileName(name, version) {
|
||||
return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
|
||||
}
|
||||
|
||||
export function findPackage(key) {
|
||||
const pkg = PACKAGES.find((p) => p.key === key);
|
||||
if (!pkg) throw new Error(`unknown package key: ${key}`);
|
||||
return pkg;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import { ROOT } from "./packages.mjs";
|
||||
|
||||
export function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? ROOT,
|
||||
stdio: options.stdio ?? "inherit",
|
||||
env: { ...process.env, ...options.env },
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const detail = result.stderr?.trim() || result.stdout?.trim();
|
||||
throw new Error(`${command} ${args.join(" ")} failed${detail ? `\n${detail}` : ""}`);
|
||||
}
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
export function runCapture(command, args, options = {}) {
|
||||
return run(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
export function tryRun(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? ROOT,
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, ...options.env },
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: (result.stdout ?? "").trim(),
|
||||
stderr: (result.stderr ?? "").trim(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { PACKAGES, ROOT, readPackageJson } from "./packages.mjs";
|
||||
|
||||
const README_FILES = ["README.md", "README_CN.md"];
|
||||
|
||||
export function assertReadmeSync() {
|
||||
for (const file of README_FILES) {
|
||||
const rootBuf = readFileSync(join(ROOT, file));
|
||||
const cliBuf = readFileSync(join(ROOT, "packages/cli", file));
|
||||
if (!rootBuf.equals(cliBuf)) {
|
||||
throw new Error(
|
||||
`${file} differs between root and packages/cli. ` +
|
||||
`Sync them manually (e.g. \`cp ${file} packages/cli/${file}\`).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAndValidatePackages() {
|
||||
const jsonByKey = new Map();
|
||||
for (const pkg of PACKAGES) {
|
||||
const json = readPackageJson(pkg);
|
||||
if (json.name !== pkg.name) {
|
||||
throw new Error(`${pkg.dir} name must be ${pkg.name}, got ${json.name}`);
|
||||
}
|
||||
jsonByKey.set(pkg.key, json);
|
||||
}
|
||||
|
||||
const coreJson = jsonByKey.get("core");
|
||||
const cliJson = jsonByKey.get("cli");
|
||||
|
||||
if (cliJson.version !== coreJson.version) {
|
||||
throw new Error(
|
||||
`core and cli versions must match, got ${coreJson.version} and ${cliJson.version}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const cliCoreDep = cliJson.dependencies?.["bailian-cli-core"];
|
||||
if (cliCoreDep !== "workspace:*") {
|
||||
throw new Error(
|
||||
`packages/cli source dependency on bailian-cli-core must be "workspace:*", got ${cliCoreDep}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { coreJson, cliJson };
|
||||
}
|
||||
|
||||
const RESERVED_CHANNELS = new Set(["latest", "beta", "alpha", "next", "rc", "canary", "dev"]);
|
||||
const CHANNEL_FORMAT = /^[a-z][a-z0-9-]{1,30}$/;
|
||||
|
||||
export function assertChannel(channel) {
|
||||
if (!channel || typeof channel !== "string") {
|
||||
throw new Error("channel is required");
|
||||
}
|
||||
if (!CHANNEL_FORMAT.test(channel)) {
|
||||
throw new Error(
|
||||
`channel "${channel}" must match ${CHANNEL_FORMAT} (lowercase letters/digits/dashes, start with a letter, 2-31 chars).`,
|
||||
);
|
||||
}
|
||||
if (RESERVED_CHANNELS.has(channel)) {
|
||||
throw new Error(
|
||||
`channel "${channel}" is reserved (${[...RESERVED_CHANNELS].join(", ")}); pick a different name.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { parseArgs } from "util";
|
||||
|
||||
import { runCheck } from "./check.mjs";
|
||||
import { headSha7, utcDateStamp } from "./lib/git.mjs";
|
||||
import { npmViewExists, pnpmPublish } from "./lib/npm.mjs";
|
||||
import {
|
||||
findPackage,
|
||||
packageJsonPath,
|
||||
readPackageJson,
|
||||
writePackageJson,
|
||||
} from "./lib/packages.mjs";
|
||||
import { assertChannel } from "./lib/validate.mjs";
|
||||
|
||||
function log(msg = "") {
|
||||
process.stdout.write(`${msg}\n`);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
log(`\n==> ${msg}`);
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
channel: { type: "string" },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
});
|
||||
const channel = values.channel;
|
||||
const dryRun = values["dry-run"];
|
||||
assertChannel(channel);
|
||||
|
||||
if (!dryRun && !process.env.CI) {
|
||||
process.stderr.write("publish-channel is CI-only. Pass --dry-run to test locally.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const core = findPackage("core");
|
||||
const cli = findPackage("cli");
|
||||
const corePath = packageJsonPath(core);
|
||||
const cliPath = packageJsonPath(cli);
|
||||
const coreOriginal = readFileSync(corePath, "utf-8");
|
||||
const cliOriginal = readFileSync(cliPath, "utf-8");
|
||||
|
||||
function restoreOriginals() {
|
||||
writeFileSync(corePath, coreOriginal);
|
||||
writeFileSync(cliPath, cliOriginal);
|
||||
}
|
||||
|
||||
try {
|
||||
step("compute channel version");
|
||||
const sha = headSha7();
|
||||
const date = utcDateStamp();
|
||||
const betaVersion = `0.0.0-beta-${sha}-${date}`;
|
||||
log(`channel=${channel} version=${betaVersion}`);
|
||||
|
||||
step("temporarily bump package.json (not committed)");
|
||||
const coreJson = readPackageJson(core);
|
||||
const cliJson = readPackageJson(cli);
|
||||
coreJson.version = betaVersion;
|
||||
cliJson.version = betaVersion;
|
||||
writePackageJson(core, coreJson);
|
||||
writePackageJson(cli, cliJson);
|
||||
// pnpm pack resolves `workspace:*` to the in-tree version, so CLI tarball
|
||||
// will depend on bailian-cli-core@<betaVersion> after this bump.
|
||||
|
||||
await runCheck();
|
||||
|
||||
step(`idempotency: check ${betaVersion} against registry`);
|
||||
const corePublished = npmViewExists(core.name, betaVersion);
|
||||
const cliPublished = npmViewExists(cli.name, betaVersion);
|
||||
log(`${core.name}@${betaVersion}: ${corePublished ? "already published" : "to publish"}`);
|
||||
log(`${cli.name}@${betaVersion}: ${cliPublished ? "already published" : "to publish"}`);
|
||||
if (corePublished && cliPublished) {
|
||||
log("\nboth packages already published; nothing to do.");
|
||||
} else {
|
||||
if (!corePublished) {
|
||||
step(`publish ${core.name}@${betaVersion} (tag=${channel}, provenance)`);
|
||||
pnpmPublish(core, { tag: channel, provenance: true, dryRun });
|
||||
}
|
||||
if (!cliPublished) {
|
||||
step(`publish ${cli.name}@${betaVersion} (tag=${channel}, provenance)`);
|
||||
pnpmPublish(cli, { tag: channel, provenance: true, dryRun });
|
||||
}
|
||||
}
|
||||
|
||||
log(`\nchannel release complete: ${channel}@${betaVersion}`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`\nrelease publish-channel failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
restoreOriginals();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
import { parseArgs } from "util";
|
||||
|
||||
import { runCheck } from "./check.mjs";
|
||||
import { createTag, currentBranch, isWorkingTreeClean, pushTag, tagExists } from "./lib/git.mjs";
|
||||
import { npmViewExists, pnpmPublish } from "./lib/npm.mjs";
|
||||
import { findPackage } from "./lib/packages.mjs";
|
||||
|
||||
function log(msg = "") {
|
||||
process.stdout.write(`${msg}\n`);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
log(`\n==> ${msg}`);
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
});
|
||||
const dryRun = values["dry-run"];
|
||||
|
||||
try {
|
||||
if (!dryRun && !process.env.CI) {
|
||||
throw new Error("publish-stable is CI-only. Pass --dry-run to test locally.");
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
step("preflight: working tree clean + on main");
|
||||
if (!isWorkingTreeClean()) {
|
||||
throw new Error("git working tree is not clean; commit or stash first.");
|
||||
}
|
||||
const branch = currentBranch();
|
||||
if (branch !== "main") {
|
||||
throw new Error(`must publish from main, currently on ${branch}.`);
|
||||
}
|
||||
} else {
|
||||
log("[dry-run] skipping working-tree + branch preflight");
|
||||
}
|
||||
|
||||
const { coreJson } = await runCheck();
|
||||
const version = coreJson.version; // === cliJson.version, asserted by runCheck
|
||||
|
||||
step(`idempotency: check ${version} against registry`);
|
||||
const core = findPackage("core");
|
||||
const cli = findPackage("cli");
|
||||
const corePublished = npmViewExists(core.name, version);
|
||||
const cliPublished = npmViewExists(cli.name, version);
|
||||
log(`${core.name}@${version}: ${corePublished ? "already published" : "to publish"}`);
|
||||
log(`${cli.name}@${version}: ${cliPublished ? "already published" : "to publish"}`);
|
||||
if (corePublished && cliPublished) {
|
||||
log("\nboth packages already published; nothing to do.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!corePublished) {
|
||||
step(`publish ${core.name}@${version} (tag=latest, provenance)`);
|
||||
pnpmPublish(core, { tag: "latest", provenance: true, dryRun });
|
||||
}
|
||||
if (!cliPublished) {
|
||||
step(`publish ${cli.name}@${version} (tag=latest, provenance)`);
|
||||
pnpmPublish(cli, { tag: "latest", provenance: true, dryRun });
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
log("\n[dry-run] skipping git tag");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tag = `v${version}`;
|
||||
if (tagExists(tag)) {
|
||||
log(`tag ${tag} already exists; skipping tag push`);
|
||||
} else {
|
||||
step(`tag ${tag} and push`);
|
||||
createTag(tag);
|
||||
pushTag(tag);
|
||||
}
|
||||
|
||||
log("\nstable release complete.");
|
||||
} catch (error) {
|
||||
process.stderr.write(`\nrelease publish-stable failed: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user