Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/publish-knowledge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Publish Knowledge

on:
workflow_dispatch:
inputs:
mode:
description: "Publish mode"
required: true
type: choice
options:
- channel
- stable
channel:
description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)"
required: false
type: string

concurrency:
group: publish-knowledge-${{ inputs.mode }}-${{ inputs.channel }}
cancel-in-progress: false

jobs:
publish-stable:
if: inputs.mode == 'stable'
name: publish stable (with knowledge) to npm + tag
runs-on: ubuntu-latest
environment: production # Required Reviewers gate
permissions:
contents: write # push lightweight tag to origin
id-token: write # OIDC for npm Trusted Publishing + provenance
steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
registry-url: "https://registry.npmjs.org/"

- name: Install gitleaks
run: |
set -euo pipefail
GITLEAKS_VERSION=8.21.2
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| sudo tar -xz -C /usr/local/bin gitleaks
gitleaks version

- run: pnpm install --frozen-lockfile

- name: publish-stable (with knowledge)
run: node tools/release/publish-stable.mjs --knowledge

publish-channel:
if: inputs.mode == 'channel'
name: publish beta (with knowledge) to npm
runs-on: ubuntu-latest
permissions:
contents: read # no tag, no Release; just publish
id-token: write # OIDC for npm Trusted Publishing + provenance
steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
registry-url: "https://registry.npmjs.org/"

- name: Install gitleaks
run: |
set -euo pipefail
GITLEAKS_VERSION=8.21.2
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| sudo tar -xz -C /usr/local/bin gitleaks
gitleaks version

- run: pnpm install --frozen-lockfile

- name: publish-channel (with knowledge)
run: node tools/release/publish-channel.mjs --knowledge --channel "${{ inputs.channel }}"
16 changes: 13 additions & 3 deletions tools/release/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from "url";
import { packAndScan } from "./lib/pack-scan.mjs";
import { run } from "./lib/proc.mjs";
import { assertReadmeSync, loadAndValidatePackages } from "./lib/validate.mjs";
import { ALL_PACKAGES, PACKAGES } from "./lib/packages.mjs";

function log(msg = "") {
process.stdout.write(`${msg}\n`);
Expand All @@ -17,20 +18,24 @@ function step(msg) {
* Pure-validation pipeline. Reusable from publish-stable / publish-channel.
* Returns { coreJson, cliJson } for callers that need the parsed package.jsons.
*
* @param {{ channel?: boolean }} [options]
* @param {{ channel?: boolean, knowledge?: boolean }} [options]
* @param {boolean} [options.channel] — When true (publish-channel): regenerate
* `reference/` and assert it matches git, but do not sync `SKILL.md` from the
* temporary beta `package.json` version (repo skill stays aligned with stable).
* @param {boolean} [options.knowledge] — When true: also build and validate
* knowledge-studio-cli alongside the base packages.
*/
export async function runCheck(options = {}) {
const channel = options.channel === true;
const knowledge = options.knowledge === true;
const packages = knowledge ? ALL_PACKAGES : PACKAGES;

step("pnpm install --frozen-lockfile");
run("pnpm", ["install", "--frozen-lockfile"]);

step("metadata: README sync, version consistency, workspace:* dep");
assertReadmeSync();
const { coreJson, cliJson } = loadAndValidatePackages();
const { coreJson, cliJson } = loadAndValidatePackages({ packages });
log(`bailian-cli-core@${coreJson.version}`);
log(`bailian-cli@${cliJson.version}`);

Expand Down Expand Up @@ -63,8 +68,13 @@ export async function runCheck(options = {}) {
step("build bailian-cli");
run("pnpm", ["--filter", "bailian-cli", "run", "build"]);

if (knowledge) {
step("build knowledge-studio-cli");
run("pnpm", ["--filter", "knowledge-studio-cli", "run", "build"]);
}

step("pack + scan (publint, gitleaks)");
packAndScan({ log });
packAndScan({ log, packages });

log("\nrelease check passed.");
return { coreJson, cliJson };
Expand Down
5 changes: 3 additions & 2 deletions tools/release/lib/pack-scan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ function extractTarball(tarball, tempDir, key) {
return extractDir;
}

export function packAndScan({ log }) {
export function packAndScan({ log, packages }) {
const pkgs = packages ?? PACKAGES;
const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-"));
try {
for (const pkg of PACKAGES) {
for (const pkg of pkgs) {
const json = readPackageJson(pkg);
log(`packing ${pkg.name}@${json.version}`);
const tarball = pnpmPack(pkg, tempDir, json);
Expand Down
5 changes: 5 additions & 0 deletions tools/release/lib/packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export const PACKAGES = [
{ key: "cli", dir: "packages/cli", name: "bailian-cli" },
];

// knowledge-studio-cli shares the same library deps as bailian-cli.
// Published via a separate workflow (publish-knowledge.yml) with --knowledge flag.
export const KSCLI_PACKAGE = { key: "kscli", dir: "packages/kscli", name: "knowledge-studio-cli" };
export const ALL_PACKAGES = [...PACKAGES, KSCLI_PACKAGE];

export function readJson(path) {
return JSON.parse(readFileSync(path, "utf-8"));
}
Expand Down
31 changes: 18 additions & 13 deletions tools/release/lib/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ export function assertReadmeSync() {
}
}

export function loadAndValidatePackages() {
export function loadAndValidatePackages({ packages } = {}) {
const pkgs = packages ?? PACKAGES;
const internalNames = new Set(pkgs.map((p) => p.name));
const jsonByKey = new Map();
for (const pkg of PACKAGES) {
for (const pkg of pkgs) {
const json = readPackageJson(pkg);
if (json.name !== pkg.name) {
throw new Error(`${pkg.dir} name must be ${pkg.name}, got ${json.name}`);
Expand All @@ -30,18 +32,21 @@ export function loadAndValidatePackages() {

const coreJson = jsonByKey.get("core");
const cliJson = jsonByKey.get("cli");
const version = coreJson.version;

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}.`,
);
for (const pkg of pkgs) {
const json = jsonByKey.get(pkg.key);
if (json.version !== version) {
throw new Error(
`all package versions must match ${version} (bailian-cli-core), ` +
`but ${pkg.name} is ${json.version}.`,
);
}
for (const [dep, range] of Object.entries(json.dependencies ?? {})) {
if (internalNames.has(dep) && range !== "workspace:*") {
throw new Error(`${pkg.name} dependency on ${dep} must be "workspace:*", got ${range}.`);
}
}
}

return { coreJson, cliJson };
Expand Down
62 changes: 31 additions & 31 deletions tools/release/publish-channel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { runCheck } from "./check.mjs";
import { headSha7, utcDateStamp } from "./lib/git.mjs";
import { npmViewExists, pnpmPublish } from "./lib/npm.mjs";
import {
findPackage,
ALL_PACKAGES,
PACKAGES,
packageJsonPath,
readPackageJson,
writePackageJson,
Expand All @@ -25,28 +26,30 @@ const { values } = parseArgs({
options: {
channel: { type: "string" },
"dry-run": { type: "boolean", default: false },
knowledge: { type: "boolean", default: false },
},
allowPositionals: false,
});
const channel = values.channel;
const dryRun = values["dry-run"];
const knowledge = values.knowledge;
const packages = knowledge ? ALL_PACKAGES : PACKAGES;
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");
// Snapshot every package.json so the temporary version bump is reverted in
// `finally`, even when the release fails midway.
const originals = packages.map((pkg) => {
const path = packageJsonPath(pkg);
return { pkg, path, content: readFileSync(path, "utf-8") };
});

function restoreOriginals() {
writeFileSync(corePath, coreOriginal);
writeFileSync(cliPath, cliOriginal);
for (const { path, content } of originals) writeFileSync(path, content);
}

try {
Expand All @@ -57,32 +60,29 @@ try {
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.
for (const pkg of packages) {
const json = readPackageJson(pkg);
json.version = betaVersion;
writePackageJson(pkg, json);
}

await runCheck({ channel: true });
await runCheck({ channel: true, knowledge });

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.");
const published = new Map();
for (const pkg of packages) {
const exists = npmViewExists(pkg.name, betaVersion);
published.set(pkg.key, exists);
log(`${pkg.name}@${betaVersion}: ${exists ? "already published" : "to publish"}`);
}
if (packages.every((pkg) => published.get(pkg.key))) {
log("\nall 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 });
// Publish in dependency order.
for (const pkg of packages) {
if (published.get(pkg.key)) continue;
step(`publish ${pkg.name}@${betaVersion} (tag=${channel}, provenance)`);
pnpmPublish(pkg, { tag: channel, provenance: true, dryRun });
}
}

Expand Down
37 changes: 19 additions & 18 deletions tools/release/publish-stable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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";
import { ALL_PACKAGES, findPackage, PACKAGES } from "./lib/packages.mjs";

function log(msg = "") {
process.stdout.write(`${msg}\n`);
Expand All @@ -17,10 +17,13 @@ function step(msg) {
const { values } = parseArgs({
options: {
"dry-run": { type: "boolean", default: false },
knowledge: { type: "boolean", default: false },
},
allowPositionals: false,
});
const dryRun = values["dry-run"];
const knowledge = values.knowledge;
const packages = knowledge ? ALL_PACKAGES : PACKAGES;

try {
if (!dryRun && !process.env.CI) {
Expand All @@ -40,28 +43,26 @@ try {
log("[dry-run] skipping working-tree + branch preflight");
}

const { coreJson } = await runCheck();
const version = coreJson.version; // === cliJson.version, asserted by runCheck
const { coreJson } = await runCheck({ knowledge });
const version = coreJson.version; // all packages share this, 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.");
const published = new Map();
for (const pkg of packages) {
const exists = npmViewExists(pkg.name, version);
published.set(pkg.key, exists);
log(`${pkg.name}@${version}: ${exists ? "already published" : "to publish"}`);
}
if (packages.every((pkg) => published.get(pkg.key))) {
log("\nall 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 });
// Publish in dependency order.
for (const pkg of packages) {
if (published.get(pkg.key)) continue;
step(`publish ${pkg.name}@${version} (tag=latest, provenance)`);
pnpmPublish(pkg, { tag: "latest", provenance: true, dryRun });
}

if (dryRun) {
Expand Down