diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 844f8b0..fcb0c6c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,6 +10,7 @@ on: options: - bailian-cli - knowledge-studio-cli + - bailian-kb-dsh mode: description: "Publish mode" required: true @@ -18,7 +19,7 @@ on: - channel - stable channel: - description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli is npm-only." + description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli and bailian-kb-dsh are npm-only." required: false type: string @@ -28,7 +29,7 @@ concurrency: jobs: publish-stable: - if: inputs.mode == 'stable' + if: inputs.mode == 'stable' && inputs.package != 'bailian-kb-dsh' name: publish stable (${{ inputs.package }}) to npm + binary + tag runs-on: ubuntu-latest environment: production # Required Reviewers gate @@ -83,7 +84,7 @@ jobs: run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} publish-channel: - if: inputs.mode == 'channel' + if: inputs.mode == 'channel' && inputs.package != 'bailian-kb-dsh' name: publish channel (${{ inputs.package }}) to npm + binary runs-on: ubuntu-latest permissions: @@ -138,3 +139,49 @@ jobs: BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }} BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }} run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}" + + # bailian-kb-dsh is the dsh plugin (downstream host adapter): independent version, + # tsc + tsdown build, npm-only. It shares this workflow's entry UI and setup steps + # but NOT publish-stable.mjs / publish-channel.mjs — those broadcast one version + # across the locked bl package set and produce binary artifacts, neither of which + # applies here. See docs/agents/dsh-plugin.md. + publish-kb-dsh: + if: inputs.package == 'bailian-kb-dsh' + name: publish ${{ inputs.mode }} (bailian-kb-dsh) to npm + runs-on: ubuntu-latest + # stable goes through the Required Reviewers gate, same as the bl stable job; + # channel stays ungated so dist-tag drops need no approval. + environment: ${{ inputs.mode == 'stable' && 'production' || '' }} + permissions: + contents: write # push the bailian-kb-dsh-v tag (stable only) + id-token: write # OIDC for npm Trusted Publishing + provenance + steps: + - name: Require channel input + if: ${{ inputs.mode == 'channel' && inputs.channel == '' }} + run: | + echo "::error::mode=channel requires the workflow input \"channel\" (npm dist-tag, e.g. mcp / plugin). Leave mode=stable if you do not need a dist-tag." + exit 1 + + - 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-kb-dsh + run: node tools/release/publish-kb-dsh.mjs ${{ inputs.mode == 'channel' && format('--channel "{0}"', inputs.channel) || '' }} diff --git a/AGENTS.md b/AGENTS.md index 859e2e8..f24cb02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `bl skill init` 安装(装齐 源码包的 `version` 当前保持一致: `packages/core`、`packages/runtime`、`packages/commands`、`packages/cli`、`packages/kscli`。做版本 bump 时一动多动。release 工具当前强校验 / 发布范围以 `tools/release/lib/packages.mjs` 为准;把新包纳入发布前必须同步该清单和 [publish.md](docs/agents/publish.md)。 -**例外**: `packages/bailian-kb-dsh` 不参与这个锁步(独立 `0.1.x`,跟随 dsh rc 节奏),也不在 release 白名单里,由独立 workflow 发布。 +**例外**: `packages/bailian-kb-dsh` 不参与这个锁步(独立 `0.1.x`,跟随 dsh rc 节奏),也不在 release 白名单里;它走 `publish.yml` 里 `package=bailian-kb-dsh` 的独立 job(`tools/release/publish-kb-dsh.mjs`)。 ### 2. 分层边界 diff --git a/docs/agents/dsh-plugin.md b/docs/agents/dsh-plugin.md index 21de859..c68b407 100644 --- a/docs/agents/dsh-plugin.md +++ b/docs/agents/dsh-plugin.md @@ -12,12 +12,12 @@ 它是**下游宿主适配层**:依赖方向朝外(消费 `bl` CLI 与百炼 API,装进 DeepSeek Harness 运行),不是 `core → runtime → commands → 产品入口` 这条链上的一环。由此带来四条与 `packages/*` 通行约定的**故意偏离**: -| 项 | 本包 | 其他包 | 原因 | -| -------- | -------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ | -| 版本 | 独立 `0.1.x` | core/runtime/commands/cli/kscli 锁步 | 跟随 dsh 的 rc 节奏,与 `bl` 发版无关;不在 `tools/release/lib/packages.mjs` 白名单里 | -| 构建 | `tsc` + `tsdown` | `vp pack` | 浏览器半需要 `__ModuleLoader__` banner/footer 与 lightningcss CSS Modules 内联,`vp pack` 产不出 | -| 发布 | `.github/workflows/publish-kb-dsh.yml` | `publish.yml` | 不在 `bailian-cli` 依赖闭包内,走独立通道 | -| tsconfig | 三个 | 一个 | 见下 | +| 项 | 本包 | 其他包 | 原因 | +| -------- | ---------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| 版本 | 独立 `0.1.x` | core/runtime/commands/cli/kscli 锁步 | 跟随 dsh 的 rc 节奏,与 `bl` 发版无关;不在 `tools/release/lib/packages.mjs` 白名单里 | +| 构建 | `tsc` + `tsdown` | `vp pack` | 浏览器半需要 `__ModuleLoader__` banner/footer 与 lightningcss CSS Modules 内联,`vp pack` 产不出 | +| 发布 | `publish.yml` 里 `package=bailian-kb-dsh` job,走 `publish-kb-dsh.mjs` | `publish.yml` 里 `publish-stable/channel.mjs` | 不在 `bailian-cli` 依赖闭包内,版本与构建都不同,不能与 `bl` 共用同一条 script | +| tsconfig | 三个 | 一个 | 见下 | ## tsconfig 三件套(改动前先读) @@ -78,6 +78,39 @@ dsh plugin --profile dev add <本仓库>/packages/bailian-kb-dsh dsh --profile dev --dump-config # 应能看到 tool-bailian-kb row ``` +## 发布 + +入口与 `bl` 共用:Actions → **Publish** → `package=bailian-kb-dsh` + `mode=stable|channel`。共享的只有 workflow 入口与 checkout/pnpm/node/gitleaks/install 几步 setup;它走自己的 `tools/release/publish-kb-dsh.mjs`,**不**复用 `publish-stable.mjs` / `publish-channel.mjs`。 + +| | stable | channel | +| --------- | ------------------------------------------------------------------- | ------------------------------------------------------- | +| 版本 | `package.json` 当前值(先手动 bump 并提交) | 临时 `0.0.0-beta--`,`finally` 还原,不提交 | +| npm tag | `latest` | 你传的 `channel`(dist-tag) | +| preflight | 工作区干净 + 必须在 `main` | 无 | +| git tag | `bailian-kb-dsh-v`(与 `bl` 的 `v` 错开命名空间) | 不打 tag | +| 审批 | `environment: production`(Required Reviewers) | 无 | +| 产物 | npm only | npm only | + +两种模式都跑:`build`(tsc + web 隔离检查 + tsdown)→ 幂等性查重 → `pnpm pack` → `publint` + `gitleaks` → `pnpm publish --provenance`。 + +本地验证(不真发,需本地装 gitleaks): + +```sh +node tools/release/publish-kb-dsh.mjs --dry-run +node tools/release/publish-kb-dsh.mjs --dry-run --channel dsh-beta +``` + +### 首发前的 npm 侧前置(仓外操作,一次性) + +1. 占住包名 `bailian-kb-dsh`(OIDC Trusted Publishing 无法给不存在的包首发) +2. 在 npm 包设置里绑 Trusted Publisher:仓库 `modelstudioai/cli`、workflow `publish.yml`;stable 走 environment `production`,channel 无 environment + +未完成这两步前触发 workflow 会在 `pnpm publish --provenance` 这一步失败(前面的 build / scan 仍会正常跑完)。 + +### 已知待办 + +- `publint` 会报一条 warning:`exports["./client"]` 是 CJS 但包为 `type: module`,建议改 `.cjs` 扩展名。这是迁移前就存在的状态,warning 不阻断发布;真要改得同时动宿主按 `exports["./client"]` 解析 `/plugins//client.js` 的 URL 契约,需 dsh 侧一起验证,不要单方面改 + ## 相关文档 - 设计与实现计划归档:[docs/kb-dsh/](../kb-dsh/) diff --git a/docs/agents/publish.md b/docs/agents/publish.md index faeb685..b83f6c6 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -24,6 +24,10 @@ publish-stable.mjs / publish-channel.mjs ← 唯一发版入口 `tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。 +### bailian-kb-dsh(独立版本、npm-only) + +同一个 Publish 入口,`package=bailian-kb-dsh`。它走单独的 `tools/release/publish-kb-dsh.mjs`,不复用 `publish-stable.mjs` / `publish-channel.mjs`(版本独立、无 binary、无 OSS CDN)。详见 [dsh-plugin.md](dsh-plugin.md#发布)。 + 两种模式: | 模式 | 用途 | 触发方式 | diff --git a/packages/bailian-kb-dsh/src/bl-cli.ts b/packages/bailian-kb-dsh/src/bl-cli.ts index acaa999..4a33707 100644 --- a/packages/bailian-kb-dsh/src/bl-cli.ts +++ b/packages/bailian-kb-dsh/src/bl-cli.ts @@ -1,15 +1,13 @@ /** - * Host-side bridge to the locally installed `bl` CLI (bailian-cli). The CLI's - * credential file (`~/.bailian/config.json`) is its documented "output": the - * console browser login (`bl auth login --console`) validates the api key the - * console issues and persists it there in plain JSON — no CLI command ever - * echoes the value back (auth status / config show both mask), so reading the - * file is the only way to obtain it programmatically. + * Host-side bridge to the locally installed `bl` CLI (bailian-cli): reads the + * credentials an earlier `bl auth login` already stored, so a user who has + * already set the CLI up does not have to configure this plugin a second time. * - * Starting a login is NOT done through the CLI: it hard-codes - * `needApiKey: !hasApiKey` and so refuses to have a fresh key issued once any - * key is stored. See `console-login.ts`, which speaks the callback protocol - * directly and always asks for one. + * Reading the CLI's credential file (`~/.bailian/config.json`) is the only way + * to obtain the value programmatically — CLI commands mask stored keys on + * output, so there is nothing to parse from stdout. + * + * Starting a login is NOT done through the CLI; see `console-login.ts`. */ import { readFileSync } from "node:fs"; diff --git a/packages/bailian-kb-dsh/src/console-login.ts b/packages/bailian-kb-dsh/src/console-login.ts index ebed6d7..62b28f5 100644 --- a/packages/bailian-kb-dsh/src/console-login.ts +++ b/packages/bailian-kb-dsh/src/console-login.ts @@ -1,20 +1,15 @@ /** - * Self-contained Bailian console login: the plugin speaks the console's - * callback protocol itself instead of shelling out to `bl auth login --console`. + * Self-contained Bailian console login: the plugin drives the console's browser + * login itself and persists what comes back, rather than shelling out to the + * `bl` CLI. * - * Why not the CLI: it hard-codes `needApiKey: !hasApiKey`, so once any api key - * is stored it never asks the console to issue a fresh one — you end up pairing - * an old account's key with a new account's workspace id, and nothing warns - * you. Driving the flow here lets us always pass `needapikey=true`, so the key - * and the workspace id both come from the account that just signed in, and the - * values land straight in the dsh stores without transiting the CLI's - * `~/.bailian/config.json`. + * Driving the flow here lets it always request a freshly issued api key, so the + * key and the workspace id both come from the account that just signed in, and + * the values land straight in the dsh stores. * - * Protocol (mirrors the CLI's implementation): bind a loopback-only port, open - * `/console-login?notice=127.0.0.1:?state=&needapikey=true`, - * then accept one callback carrying the credentials as query parameters or a - * JSON / form-encoded body. Note the URL shape: `state` is part of the `notice` - * value (separated by `?`), not a sibling query parameter. + * Shape of the flow: bind a loopback-only port, open the console login page + * pointed at that port, then accept one callback carrying the credentials as + * query parameters or a JSON / form-encoded body. */ import { execFile } from "node:child_process"; @@ -30,12 +25,12 @@ const CONSOLE_ORIGINS: Record = { /** How long the loopback listener waits for the browser callback. */ const LOGIN_TIMEOUT_MS = 15 * 60 * 1000; -/** Upper bound on a callback body, matching the CLI's limit. */ +/** Upper bound on a callback body. */ const MAX_CALLBACK_BODY = 65536; /** Credentials the console callback can carry. */ export interface ConsoleLoginCredentials { - /** Freshly issued DashScope api key (`needapikey=true` asks for one). */ + /** Freshly issued DashScope api key. */ apiKey?: string; /** Workspace id of the account that signed in. */ workspaceId?: string; @@ -95,8 +90,8 @@ function readBody(req: http.IncomingMessage): Promise { /** * Parse a callback body as JSON (optionally wrapped in `data`) or as form - * encoding. Content-type is a hint only — the CLI falls back to trying both, - * and so do we, because the console has shipped both shapes. + * encoding. Content-type is a hint only: both shapes occur in practice, so both + * are attempted. * @param raw - the raw request body. * @returns the flattened fields; an unparseable body yields no fields. */ @@ -278,8 +273,8 @@ export async function startConsoleLogin(opts: { return { status: "failed", reason }; } - // `state` rides inside the `notice` value, and `needapikey=true` is the whole - // point: it makes the console issue a key for the account signing in. + // Ask for a freshly issued key, so the key and the workspace id cannot end up + // belonging to two different accounts. const origin = (opts.site !== undefined ? CONSOLE_ORIGINS[opts.site] : undefined) ?? CONSOLE_ORIGINS.domestic!; const loginUrl = diff --git a/packages/bailian-kb-dsh/src/index.ts b/packages/bailian-kb-dsh/src/index.ts index fcada15..6489521 100644 --- a/packages/bailian-kb-dsh/src/index.ts +++ b/packages/bailian-kb-dsh/src/index.ts @@ -561,9 +561,9 @@ export function apply(ctx: Context, config: Config): void { sendJson(res, 200, consoleLoginState()); return; } - // Drive the console flow ourselves with `needapikey=true`, so the key - // and the workspace id both belong to the account signing in now. - // Persisting here keeps the plain key on the host. + // Drive the console flow ourselves, requesting a freshly issued key, + // so the key and the workspace id both belong to the account signing + // in now. Persisting here keeps the plain key on the host. const started = await startConsoleLogin({ onComplete: async (credentials) => { const written: string[] = []; diff --git a/packages/bailian-kb-dsh/src/services.ts b/packages/bailian-kb-dsh/src/services.ts index ba0d351..f9ab365 100644 --- a/packages/bailian-kb-dsh/src/services.ts +++ b/packages/bailian-kb-dsh/src/services.ts @@ -2,25 +2,21 @@ * Retrieval-service discovery for the plugin's internal cache. Not a model tool: * see `KB_PATHS.serviceList`. * - * Two verified server facts shape this module: - * - `page_size` is capped at 100 regardless of what is requested, so a workspace - * with hundreds of services needs many round trips. Test/CI workspaces reach - * the high hundreds (913 observed), which is pure noise for routing, so this - * module stops after {@link MAX_PAGES} and reports the shortfall instead of - * faithfully paging through it. - * - `agent_status: 'deployed'` is honored and means "deployed or edited". Only - * those are callable by the default agent version, so drafts never reach the - * model. + * Listing is paged, and a workspace can hold far more services than are useful + * for routing, so this module stops after {@link MAX_PAGES} and reports the + * shortfall rather than paging through everything. Only deployed services are + * requested: anything else is not callable by the default service version, so it + * would be noise in the catalog the model reads. */ import type { ServiceListResponse, ServiceScene } from "./api-types.js"; import type { KbClient } from "./client.js"; import { KB_PATHS } from "./endpoints.js"; -/** Server page-size maximum; larger requests are silently clamped to this. */ +/** Page size requested per listing call. */ const PAGE_SIZE = 100; -/** Pages fetched per scene before reporting truncation (200 rows is far past the useful range). */ +/** Pages fetched per scene before reporting truncation; well past the useful routing range. */ const MAX_PAGES = 2; /** One deployed retrieval or Q&A service, reduced to the fields that inform routing. */ @@ -28,11 +24,11 @@ export interface ServiceEntry { agent_id: string; agent_name: string; scene: ServiceScene; - /** `deployed` or `edited` — both are callable by the default version. */ + /** Deployment status as reported for the service; only callable states are listed. */ status: string; /** Last modification timestamp; the only signal for "which of these is in use". */ modify_time?: string; - /** Absent until the backend adds a description to the list response. */ + /** Service description, when the listing provides one. */ description?: string; } diff --git a/packages/bailian-kb-dsh/src/web/bailian-card-controller.ts b/packages/bailian-kb-dsh/src/web/bailian-card-controller.ts index 5512b37..ac8a431 100644 --- a/packages/bailian-kb-dsh/src/web/bailian-card-controller.ts +++ b/packages/bailian-kb-dsh/src/web/bailian-card-controller.ts @@ -427,15 +427,14 @@ export class BailianCardController { /** * Fetch credentials by signing in to the Bailian console. The Host drives - * the console's callback protocol itself, always asking for a freshly issued - * api key, then persists the key into the credential store and the workspace - * id into the settings section — so both values belong to the account that - * just signed in, and the plain key never rides the wire to this page. + * the console browser login itself, always requesting a freshly issued api + * key, then persists the key into the credential store and the workspace id + * into the settings section — so both values belong to the account that just + * signed in, and the plain key never rides the wire to this page. * - * Deliberately does NOT adopt the bl CLI's stored login: reusing a key from - * `~/.bailian/config.json` can pair one account's key with another account's - * workspace id (the CLI refuses to re-issue once any key is stored), and - * nothing would flag the mismatch until a knowledge-base call fails. + * Deliberately does NOT adopt the bl CLI's stored login: that path can pair + * one account's key with another account's workspace id, and nothing would + * flag the mismatch until a knowledge-base call fails. */ async autofill(): Promise { const phase = this.store.getSnapshot().autofill; @@ -452,10 +451,8 @@ export class BailianCardController { /** * Ask the Host to open the console login page, then poll for the outcome. * The Host persists the credentials itself when the callback lands, always - * asking the console to issue a fresh key — so the key and the workspace id - * both come from the account signing in. (The bl CLI's own login refuses to - * re-issue once any key is stored, which would otherwise pair an old - * account's key with a new account's workspace.) + * requesting a freshly issued key — so the key and the workspace id both come + * from the account signing in. */ private async runConsoleLogin(): Promise { let started: { status?: string; loginUrl?: string }; diff --git a/tools/release/lib/packages.mjs b/tools/release/lib/packages.mjs index 2434b9d..da145e3 100644 --- a/tools/release/lib/packages.mjs +++ b/tools/release/lib/packages.mjs @@ -19,7 +19,8 @@ export const ALL_PACKAGES = [...PACKAGES, KSCLI_PACKAGE]; // Deliberately absent from every list above: packages/bailian-kb-dsh (bailian-kb-dsh). // It is a dsh plugin — a downstream host adapter, not part of the bl release closure: // its version tracks the dsh rc cadence instead of the locked core/runtime/commands/cli/kscli -// version, and it ships through .github/workflows/publish-kb-dsh.yml. So it is exempt from +// version, and it ships through publish.yml's separate `package=bailian-kb-dsh` +// job (tools/release/publish-kb-dsh.mjs). So it is exempt from // loadAndValidatePackages' version-consistency check and from packAndScan. Not an oversight; // see docs/agents/dsh-plugin.md before adding it here. diff --git a/tools/release/publish-kb-dsh.mjs b/tools/release/publish-kb-dsh.mjs new file mode 100644 index 0000000..e2ff2e7 --- /dev/null +++ b/tools/release/publish-kb-dsh.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Publish bailian-kb-dsh (dsh plugin, downstream host adapter). + * + * Deliberately independent from publish-stable.mjs / publish-channel.mjs: + * - the plugin is NOT in the version-locked bl release set (packages.mjs + * PACKAGES / ALL_PACKAGES; see packages.mjs footer comment), + * - it uses tsc + tsdown instead of `vp pack`, and has no binary artifact, + * - it tags as `bailian-kb-dsh-v` so its lightweight tags never + * collide with the bl `v` namespace. + * + * Shared with the other publish scripts: dry-run gate, CI-only guard, + * per-mode preflight, `pnpm publish --provenance`, publint + gitleaks scan. + */ +import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { parseArgs } from "util"; + +import { + createTag, + currentBranch, + headSha7, + isWorkingTreeClean, + pushTag, + tagExists, + utcDateStamp, +} from "./lib/git.mjs"; +import { npmViewExists, pnpmPack, pnpmPublish } from "./lib/npm.mjs"; +import { ROOT } from "./lib/packages.mjs"; +import { run } from "./lib/proc.mjs"; + +const PKG = { key: "kb-dsh", dir: "packages/bailian-kb-dsh", name: "bailian-kb-dsh" }; +const PKG_JSON_PATH = join(ROOT, PKG.dir, "package.json"); + +function log(msg = "") { + process.stdout.write(`${msg}\n`); +} + +function step(msg) { + log(`\n==> ${msg}`); +} + +function readPackageJson() { + return JSON.parse(readFileSync(PKG_JSON_PATH, "utf-8")); +} + +function writePackageJson(json) { + writeFileSync(PKG_JSON_PATH, `${JSON.stringify(json, null, 2)}\n`); +} + +const { values } = parseArgs({ + options: { + channel: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + }, + allowPositionals: false, +}); +const channel = values.channel; +const dryRun = values["dry-run"]; +const isChannel = channel !== undefined && channel !== ""; + +if (!dryRun && !process.env.CI) { + process.stderr.write("publish-kb-dsh is CI-only. Pass --dry-run to test locally.\n"); + process.exit(1); +} + +// Snapshot for channel mode: the temporary version bump must be reverted in +// `finally`, even on mid-flight failure. Stable mode does not bump, so the +// snapshot is a no-op that keeps the restore path uniform. +const originalPackageJson = readFileSync(PKG_JSON_PATH, "utf-8"); +function restoreOriginal() { + writeFileSync(PKG_JSON_PATH, originalPackageJson); +} + +try { + if (isChannel) { + step(`channel release: ${channel}`); + } else { + step("stable release"); + if (!dryRun) { + 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"); + } + } + + // Resolve the version we are about to publish. + const originalVersion = readPackageJson().version; + let publishVersion = originalVersion; + if (isChannel) { + // Match the shape used by publish-channel.mjs (bl channel releases) so + // consumers see a familiar dist-tag payload; the leading 0.0.0 keeps + // semver from ever preferring a beta over a real release. + const sha = headSha7(); + const stamp = utcDateStamp(); + publishVersion = `0.0.0-beta-${sha}-${stamp}`; + step(`temporarily bump ${PKG.name} to ${publishVersion} (not committed)`); + const json = readPackageJson(); + json.version = publishVersion; + writePackageJson(json); + } + log(`${PKG.name}@${publishVersion}`); + + step(`build ${PKG.name}`); + run("pnpm", ["--filter", PKG.name, "run", "build"]); + + step(`idempotency: check ${publishVersion} against registry`); + const alreadyPublished = npmViewExists(PKG.name, publishVersion); + log(`${PKG.name}@${publishVersion}: ${alreadyPublished ? "already published" : "to publish"}`); + + if (alreadyPublished) { + if (!isChannel) { + throw new Error( + `version ${publishVersion} is already published; bump ${PKG.dir}/package.json before retrying.`, + ); + } + log("channel version already published; skipping npm publish"); + } else { + step("pack + scan (publint, gitleaks)"); + const tempDir = mkdtempSync(join(tmpdir(), "bailian-kb-dsh-release-")); + try { + const packJson = readPackageJson(); + const tarball = pnpmPack(PKG, tempDir, packJson); + run("tar", ["-xzf", tarball, "-C", tempDir], { stdio: "pipe" }); + const extractDir = join(tempDir, `extract-${PKG.key}`); + renameSync(join(tempDir, "package"), extractDir); + run("npx", ["--yes", "publint", extractDir]); + run("gitleaks", ["detect", "--source", extractDir, "--no-git", "--redact"]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + + const npmTag = isChannel ? channel : "latest"; + step(`publish ${PKG.name}@${publishVersion} (tag=${npmTag}, provenance)`); + pnpmPublish(PKG, { tag: npmTag, provenance: true, dryRun }); + } + + if (isChannel) { + log(`\nchannel release complete: ${channel}@${publishVersion} (npm-only, no tag)`); + } else { + // Namespaced tag: bl uses `v`, so we prefix with the package name + // to avoid colliding when a bl release happens to share the same version + // fragment. + const tag = `${PKG.name}-v${publishVersion}`; + if (dryRun) { + log("\n[dry-run] skipping git tag"); + } else 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: ${PKG.name}@${publishVersion} (npm + tag)`); + } +} catch (error) { + process.stderr.write(`\nrelease publish-kb-dsh failed: ${error.message}\n`); + // Use exitCode (not process.exit) so `finally` restores any channel bump. + process.exitCode = 1; +} finally { + restoreOriginal(); +}