From a966b4077f10cbe2cb541123e64c362fa08d98be Mon Sep 17 00:00:00 2001 From: "zeyu.fz" Date: Fri, 26 Jun 2026 17:20:25 +0800 Subject: [PATCH] feat(release): add --knowledge flag and publish-knowledge.yml workflow - packages.mjs: export KSCLI_PACKAGE and ALL_PACKAGES for knowledge-studio-cli - check.mjs: support knowledge option to build/validate kscli - validate.mjs: accept packages param, validate all packages in lockstep - pack-scan.mjs: accept packages param - publish-stable.mjs: refactor to iterate PACKAGES array; add --knowledge flag - publish-channel.mjs: refactor to iterate PACKAGES array; add --knowledge flag - New workflow publish-knowledge.yml: triggers publish with --knowledge flag The original publish.yml (without --knowledge) publishes only core + cli. The new publish-knowledge.yml publishes core + cli + knowledge-studio-cli. --- .github/workflows/publish-knowledge.yml | 86 +++++++++++++++++++++++++ tools/release/check.mjs | 16 ++++- tools/release/lib/pack-scan.mjs | 5 +- tools/release/lib/packages.mjs | 5 ++ tools/release/lib/validate.mjs | 31 +++++---- tools/release/publish-channel.mjs | 62 +++++++++--------- tools/release/publish-stable.mjs | 37 +++++------ 7 files changed, 175 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/publish-knowledge.yml diff --git a/.github/workflows/publish-knowledge.yml b/.github/workflows/publish-knowledge.yml new file mode 100644 index 0000000..8f6d644 --- /dev/null +++ b/.github/workflows/publish-knowledge.yml @@ -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 }}" diff --git a/tools/release/check.mjs b/tools/release/check.mjs index aad327f..f5f61a0 100644 --- a/tools/release/check.mjs +++ b/tools/release/check.mjs @@ -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`); @@ -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}`); @@ -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 }; diff --git a/tools/release/lib/pack-scan.mjs b/tools/release/lib/pack-scan.mjs index 5825d7b..b7b0d8e 100644 --- a/tools/release/lib/pack-scan.mjs +++ b/tools/release/lib/pack-scan.mjs @@ -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); diff --git a/tools/release/lib/packages.mjs b/tools/release/lib/packages.mjs index 6416ea1..285f6be 100644 --- a/tools/release/lib/packages.mjs +++ b/tools/release/lib/packages.mjs @@ -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")); } diff --git a/tools/release/lib/validate.mjs b/tools/release/lib/validate.mjs index 27b51ae..bb0fe92 100644 --- a/tools/release/lib/validate.mjs +++ b/tools/release/lib/validate.mjs @@ -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}`); @@ -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 }; diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs index 7253d7e..9c24f49 100644 --- a/tools/release/publish-channel.mjs +++ b/tools/release/publish-channel.mjs @@ -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, @@ -25,11 +26,14 @@ 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) { @@ -37,16 +41,15 @@ if (!dryRun && !process.env.CI) { 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 { @@ -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@ 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 }); } } diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs index 4891276..13c6359 100644 --- a/tools/release/publish-stable.mjs +++ b/tools/release/publish-stable.mjs @@ -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`); @@ -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) { @@ -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) {