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
73 changes: 73 additions & 0 deletions .github/workflows/cleanup-npm-tags.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions docs/agents/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 交换)
Expand Down
138 changes: 138 additions & 0 deletions tools/release/cleanup-npm-tags.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
126 changes: 126 additions & 0 deletions tools/release/cleanup-npm-tags.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading