diff --git a/.github/workflows/cleanup-npm-tags.yml b/.github/workflows/cleanup-npm-tags.yml new file mode 100644 index 00000000..c79e67d5 --- /dev/null +++ b/.github/workflows/cleanup-npm-tags.yml @@ -0,0 +1,73 @@ +name: Clean npm tags +run-name: Clean npm tags (${{ inputs.dry_run && 'preview' || 'delete' }}) + +on: + workflow_dispatch: + inputs: + bailian_cli: + description: "bailian-cli" + type: boolean + default: true + bailian_cli_core: + description: "bailian-cli-core" + type: boolean + default: true + bailian_cli_runtime: + description: "bailian-cli-runtime" + type: boolean + default: true + bailian_cli_commands: + description: "bailian-cli-commands" + type: boolean + default: true + knowledge_studio_cli: + description: "knowledge-studio-cli" + type: boolean + default: true + bailian_kb_dsh: + description: "bailian-kb-dsh" + type: boolean + default: true + tags: + description: "Tags to remove (spaces, commas or newlines). latest is protected." + type: string + required: true + dry_run: + description: "Preview only / 只预览,不删除" + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: cleanup-npm-tags + cancel-in-progress: false + +jobs: + cleanup: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org/" + package-manager-cache: false + - name: Install npm with OIDC dist-tag support + run: npm install --global npm@11.21.0 + - name: Clean selected tags + env: + CLEANUP_CLI: ${{ inputs.bailian_cli }} + CLEANUP_CORE: ${{ inputs.bailian_cli_core }} + CLEANUP_RUNTIME: ${{ inputs.bailian_cli_runtime }} + CLEANUP_COMMANDS: ${{ inputs.bailian_cli_commands }} + CLEANUP_KSCLI: ${{ inputs.knowledge_studio_cli }} + CLEANUP_DSH: ${{ inputs.bailian_kb_dsh }} + CLEANUP_TAGS: ${{ inputs.tags }} + CLEANUP_DRY_RUN: ${{ inputs.dry_run }} + run: node tools/release/cleanup-npm-tags.mjs diff --git a/docs/agents/publish.md b/docs/agents/publish.md index a57fc121..0ef22de0 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -111,6 +111,18 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run ## CI 基础设施 +### 批量清理 npm dist-tags + +独立入口:GitHub Actions → **Clean npm tags**(`.github/workflows/cleanup-npm-tags.yml`)。 + +- 六个包复选框默认全选;`tags` 输入待删除的标签,支持空格、逗号或换行分隔。 +- `dry_run` 默认开启,只查询并预览。实际删除前先检查预览,再取消勾选;首次试跑只选 `bailian-cli`,标签填 `release-test`。 +- 先校验全部输入并读取所有所选包的标签,拒绝空选择、非法标签和 `latest`;标签不存在则跳过。 +- 实际删除串行调用 `npm dist-tag rm`,每次删除前检查标签是否仍指向预览版本,删除后查询确认;发生错误立即停止,已删除的标签不会自动恢复。日志及 Actions Summary 保留每条完成结果,可按同样输入重跑剩余项。 +- 此入口不发布版本、不构建二进制、不修改 CDN、不删除 npm 历史版本。 +- 所有目标包需要新增指向 `modelstudioai/cli` / `cleanup-npm-tags.yml` 的 Trusted Publisher,开启 **Allow npm dist-tag**;保留原 `publish.yml` 配置。清理入口使用 npm 11.21.0、`id-token: write` 和 GitHub 托管 runner,不使用长效 npm token。 +- 新增手动 workflow 需先进入默认分支才能触发。新 Trusted Publisher 有 48 小时首次验证期限;删除标签能否完成首次验证需要看 npm 页面实际状态,不能由清理成功推断 `Valid`。不要为验证自动发布 beta。 + - **认证**:npm OIDC Trusted Publishing(无 token),需要 `id-token: write` 权限;OSS 通道复用同一 OIDC token 向 FC 证明身份(见上文「OSS 通道」) - **GitHub Release**:`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`(stable / channel 均需) - **Node 版本**:24(npm 11.5+ 才支持 OIDC token 交换) diff --git a/tools/release/cleanup-npm-tags.mjs b/tools/release/cleanup-npm-tags.mjs new file mode 100644 index 00000000..cf78b47f --- /dev/null +++ b/tools/release/cleanup-npm-tags.mjs @@ -0,0 +1,138 @@ +import { spawn } from "node:child_process"; +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const REGISTRY = "https://registry.npmjs.org"; +const PACKAGE_INPUTS = [ + ["CLEANUP_CLI", "bailian-cli"], + ["CLEANUP_CORE", "bailian-cli-core"], + ["CLEANUP_RUNTIME", "bailian-cli-runtime"], + ["CLEANUP_COMMANDS", "bailian-cli-commands"], + ["CLEANUP_KSCLI", "knowledge-studio-cli"], + ["CLEANUP_DSH", "bailian-kb-dsh"], +]; + +function booleanInput(environment, name) { + const value = environment[name] ?? "true"; + if (value !== "true" && value !== "false") throw new Error(`${name} must be true or false`); + return value === "true"; +} + +function validateSelection({ packages, tags, dryRun }) { + const allowedPackages = new Set(PACKAGE_INPUTS.map(([, packageName]) => packageName)); + if (!packages.length || packages.some((packageName) => !allowedPackages.has(packageName))) + throw new Error("Select at least one supported package"); + if (!tags.length) throw new Error("Enter at least one tag"); + for (const tag of tags) { + if (tag === "latest") throw new Error("Deleting latest is forbidden"); + if (!/^[a-z][a-z0-9-]*$/.test(tag)) throw new Error(`Invalid tag: ${tag}`); + } + if (typeof dryRun !== "boolean") throw new Error("dryRun must be a boolean"); +} + +export function parseCleanupInputs(environment = process.env) { + const selection = { + packages: PACKAGE_INPUTS.filter(([input]) => booleanInput(environment, input)).map( + ([, packageName]) => packageName, + ), + tags: [...new Set((environment.CLEANUP_TAGS ?? "").split(/[\s,]+/).filter(Boolean))], + dryRun: booleanInput(environment, "CLEANUP_DRY_RUN"), + }; + validateSelection(selection); + return selection; +} + +async function readRegistryTags(packageName) { + const response = await fetch(`${REGISTRY}/-/package/${packageName}/dist-tags`, { + cache: "no-store", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`Cannot read ${packageName} tags: HTTP ${response.status}`); + const tags = await response.json(); + if ( + !tags || + typeof tags !== "object" || + Array.isArray(tags) || + Object.values(tags).some((version) => typeof version !== "string") + ) + throw new Error(`Invalid registry tag response for ${packageName}`); + return tags; +} + +function removeRegistryTag(packageName, tag) { + // npm >=11.21 exchanges the workflow's OIDC identity; no long-lived token is supplied. + return new Promise((resolve, reject) => { + const child = spawn("npm", ["dist-tag", "rm", packageName, tag, "--registry", REGISTRY], { + stdio: "inherit", + shell: false, + timeout: 60_000, + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`npm dist-tag rm ${packageName} ${tag} failed (${code ?? signal})`)); + }); + }); +} + +function reportEntry({ packageName, tag, version, status }) { + const message = `${status}: ${packageName} / ${tag} / ${version ?? "not present"}`; + console.log(message); + if (process.env.GITHUB_STEP_SUMMARY) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `- ${message}\n`); +} + +export async function cleanupNpmTags( + selection, + { readTags = readRegistryTags, removeTag = removeRegistryTag, report = reportEntry } = {}, +) { + validateSelection(selection); + const { packages, tags, dryRun } = selection; + const plan = []; + // Read every selected package before any deletion; request failures are not "tag absent". + for (const packageName of packages) { + const currentTags = await readTags(packageName); + for (const tag of tags) + plan.push({ + packageName, + tag, + version: Object.hasOwn(currentTags, tag) ? currentTags[tag] : null, + }); + } + + const results = []; + for (const entry of plan) { + let status = entry.version === null ? "absent" : "preview"; + if (entry.version !== null && !dryRun) { + const currentTags = await readTags(entry.packageName); + if (!Object.hasOwn(currentTags, entry.tag)) { + status = "absent"; + } else { + if (currentTags[entry.tag] !== entry.version) + throw new Error(`Tag changed since preflight: ${entry.packageName} / ${entry.tag}`); + await removeTag(entry.packageName, entry.tag); + const afterTags = await readTags(entry.packageName); + if (Object.hasOwn(afterTags, entry.tag)) + throw new Error(`Tag still exists after deletion: ${entry.packageName} / ${entry.tag}`); + status = "deleted"; + } + } + const result = { ...entry, status }; + results.push(result); + report(result); + } + return results; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const selection = parseCleanupInputs(); + console.log( + selection.dryRun ? "Preview only; no tags will be deleted." : "Deleting selected tags.", + ); + await cleanupNpmTags(selection); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/tools/release/cleanup-npm-tags.test.mjs b/tools/release/cleanup-npm-tags.test.mjs new file mode 100644 index 00000000..891b0757 --- /dev/null +++ b/tools/release/cleanup-npm-tags.test.mjs @@ -0,0 +1,126 @@ +import { describe, expect, test } from "vite-plus/test"; +import { cleanupNpmTags, parseCleanupInputs } from "./cleanup-npm-tags.mjs"; + +const onlyCli = { + CLEANUP_CORE: "false", + CLEANUP_RUNTIME: "false", + CLEANUP_COMMANDS: "false", + CLEANUP_KSCLI: "false", + CLEANUP_DSH: "false", +}; + +describe("npm tag cleanup", () => { + test("defaults to previewing all six packages and accepts mixed tag separators", () => { + expect(parseCleanupInputs({ CLEANUP_TAGS: "skill, harness\nknowledge skill" })).toEqual({ + packages: [ + "bailian-cli", + "bailian-cli-core", + "bailian-cli-runtime", + "bailian-cli-commands", + "knowledge-studio-cli", + "bailian-kb-dsh", + ], + tags: ["skill", "harness", "knowledge"], + dryRun: true, + }); + }); + + test("explicit false package selections limit an actual run to bailian-cli", () => { + expect( + parseCleanupInputs({ ...onlyCli, CLEANUP_TAGS: "release-test", CLEANUP_DRY_RUN: "false" }), + ).toEqual({ packages: ["bailian-cli"], tags: ["release-test"], dryRun: false }); + }); + + test.each(["", "latest", "skill latest", "--registry=evil", "skill;echo", "*", "1.2.3"])( + "rejects unsafe or empty tag input %j before requests", + (tags) => { + expect(() => parseCleanupInputs({ CLEANUP_TAGS: tags })).toThrow(); + }, + ); + + test("rejects no selected package and malformed booleans", () => { + expect(() => + parseCleanupInputs({ ...onlyCli, CLEANUP_CLI: "false", CLEANUP_TAGS: "skill" }), + ).toThrow(/package/i); + expect(() => parseCleanupInputs({ CLEANUP_TAGS: "skill", CLEANUP_DRY_RUN: "no" })).toThrow(); + }); + + function registryFixture() { + const registry = new Map([ + ["bailian-cli", { latest: "2.3.0", skill: "0.0.0-beta-old" }], + ["bailian-cli-core", { latest: "2.3.0" }], + ]); + return { + registry, + operations: { + readTags: async (packageName) => ({ ...registry.get(packageName) }), + removeTag: async (packageName, tag) => { + delete registry.get(packageName)[tag]; + }, + report: () => {}, + }, + }; + } + + test("preview reports present and absent tags without modifying the registry", async () => { + const { registry, operations } = registryFixture(); + const result = await cleanupNpmTags( + { packages: ["bailian-cli", "bailian-cli-core"], tags: ["skill"], dryRun: true }, + operations, + ); + expect(result).toEqual([ + { packageName: "bailian-cli", tag: "skill", version: "0.0.0-beta-old", status: "preview" }, + { packageName: "bailian-cli-core", tag: "skill", version: null, status: "absent" }, + ]); + expect(registry.get("bailian-cli")).toEqual({ latest: "2.3.0", skill: "0.0.0-beta-old" }); + }); + + test("actual cleanup removes only the selected tag and preserves latest", async () => { + const { registry, operations } = registryFixture(); + const result = await cleanupNpmTags( + { packages: ["bailian-cli", "bailian-cli-core"], tags: ["skill"], dryRun: false }, + operations, + ); + expect(result.map((entry) => entry.status)).toEqual(["deleted", "absent"]); + expect(registry.get("bailian-cli")).toEqual({ latest: "2.3.0" }); + expect(registry.get("bailian-cli-core")).toEqual({ latest: "2.3.0" }); + }); + + test("an unreadable package aborts preflight before deleting from any package", async () => { + const { registry, operations } = registryFixture(); + const readTags = operations.readTags; + operations.readTags = async (packageName) => { + if (packageName === "bailian-cli-core") throw new Error("registry unavailable"); + return readTags(packageName); + }; + await expect( + cleanupNpmTags( + { packages: ["bailian-cli", "bailian-cli-core"], tags: ["skill"], dryRun: false }, + operations, + ), + ).rejects.toThrow("registry unavailable"); + expect(registry.get("bailian-cli").skill).toBe("0.0.0-beta-old"); + }); + + test("failed deletion verification is not reported as success", async () => { + const { operations } = registryFixture(); + operations.removeTag = async () => {}; + await expect( + cleanupNpmTags({ packages: ["bailian-cli"], tags: ["skill"], dryRun: false }, operations), + ).rejects.toThrow(/still exists/i); + }); + + test("direct callers cannot bypass latest and package protections", async () => { + const { registry, operations } = registryFixture(); + await expect( + cleanupNpmTags({ packages: ["bailian-cli"], tags: ["latest"], dryRun: false }, operations), + ).rejects.toThrow(); + await expect( + cleanupNpmTags( + { packages: ["unrelated-package"], tags: ["skill"], dryRun: false }, + operations, + ), + ).rejects.toThrow(); + expect(registry.get("bailian-cli").latest).toBe("2.3.0"); + }); +});