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 abe01c5..71c4fbd 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}`); @@ -65,11 +70,13 @@ export async function runCheck(options = {}) { step("build bailian-cli"); run("pnpm", ["--filter", "bailian-cli", "run", "build"]); - step("build knowledge-studio-cli"); - run("pnpm", ["--filter", "knowledge-studio-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 aa97ea8..86f89ed 100644 --- a/tools/release/lib/packages.mjs +++ b/tools/release/lib/packages.mjs @@ -4,16 +4,20 @@ import { fileURLToPath } from "url"; export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); -// Dependency order: core ← runtime ← commands ← cli / kscli. +// Dependency order: core ← runtime ← commands ← cli. // Consumers rely on this ordering for build/publish (dependencies first). export const PACKAGES = [ { key: "core", dir: "packages/core", name: "bailian-cli-core" }, { key: "runtime", dir: "packages/runtime", name: "bailian-cli-runtime" }, { key: "commands", dir: "packages/commands", name: "bailian-cli-commands" }, { key: "cli", dir: "packages/cli", name: "bailian-cli" }, - { key: "kscli", dir: "packages/kscli", name: "knowledge-studio-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 162ab96..d860355 100644 --- a/tools/release/lib/validate.mjs +++ b/tools/release/lib/validate.mjs @@ -18,10 +18,11 @@ export function assertReadmeSync() { } } -export function loadAndValidatePackages() { - const internalNames = new Set(PACKAGES.map((p) => p.name)); +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}`); @@ -32,7 +33,7 @@ export function loadAndValidatePackages() { const coreJson = jsonByKey.get("core"); const version = coreJson.version; - for (const pkg of PACKAGES) { + for (const pkg of pkgs) { const json = jsonByKey.get(pkg.key); // All packages release in lockstep, so every version must match. if (json.version !== version) { diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs index dbf0e08..bf5edca 100644 --- a/tools/release/publish-channel.mjs +++ b/tools/release/publish-channel.mjs @@ -5,7 +5,13 @@ import { parseArgs } from "util"; import { runCheck } from "./check.mjs"; import { headSha7, utcDateStamp } from "./lib/git.mjs"; import { npmViewExists, pnpmPublish } from "./lib/npm.mjs"; -import { PACKAGES, packageJsonPath, readPackageJson, writePackageJson } from "./lib/packages.mjs"; +import { + ALL_PACKAGES, + PACKAGES, + packageJsonPath, + readPackageJson, + writePackageJson, +} from "./lib/packages.mjs"; import { assertChannel } from "./lib/validate.mjs"; function log(msg = "") { @@ -20,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) { @@ -34,7 +43,7 @@ if (!dryRun && !process.env.CI) { // 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 originals = packages.map((pkg) => { const path = packageJsonPath(pkg); return { pkg, path, content: readFileSync(path, "utf-8") }; }); @@ -51,7 +60,7 @@ try { log(`channel=${channel} version=${betaVersion}`); step("temporarily bump package.json (not committed)"); - for (const pkg of PACKAGES) { + for (const pkg of packages) { const json = readPackageJson(pkg); json.version = betaVersion; writePackageJson(pkg, json); @@ -59,20 +68,20 @@ try { // pnpm pack resolves `workspace:*` to the in-tree version, so each tarball // will depend on its siblings at after this bump. - await runCheck({ channel: true }); + await runCheck({ channel: true, knowledge }); step(`idempotency: check ${betaVersion} against registry`); const published = new Map(); - for (const pkg of PACKAGES) { + 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))) { + if (packages.every((pkg) => published.get(pkg.key))) { log("\nall packages already published; nothing to do."); } else { - // Publish in dependency order (core → runtime → commands → cli). - for (const pkg of PACKAGES) { + // Publish in dependency order (core → runtime → commands → cli [→ kscli]). + 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 70101ca..16bb15a 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 { PACKAGES } from "./lib/packages.mjs"; +import { ALL_PACKAGES, 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,23 +43,23 @@ try { log("[dry-run] skipping working-tree + branch preflight"); } - const { coreJson } = await runCheck(); + const { coreJson } = await runCheck({ knowledge }); const version = coreJson.version; // all packages share this, asserted by runCheck step(`idempotency: check ${version} against registry`); const published = new Map(); - for (const pkg of PACKAGES) { + 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))) { + if (packages.every((pkg) => published.get(pkg.key))) { log("\nall packages already published; nothing to do."); process.exit(0); } - // Publish in dependency order (core → runtime → commands → cli). - for (const pkg of PACKAGES) { + // Publish in dependency order (core → runtime → commands → cli [→ kscli]). + 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 });