Skip to content

Commit 8d9ed0b

Browse files
Merge pull request #255 from modelstudioai/codex/cleanup-npm-tags
feat(ci): add independent npm tag cleanup workflow
2 parents c3d4d4b + 7fc10cc commit 8d9ed0b

4 files changed

Lines changed: 349 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Clean npm tags
2+
run-name: Clean npm tags (${{ inputs.dry_run && 'preview' || 'delete' }})
3+
4+
on:
5+
workflow_dispatch:
6+
inputs:
7+
bailian_cli:
8+
description: "bailian-cli"
9+
type: boolean
10+
default: true
11+
bailian_cli_core:
12+
description: "bailian-cli-core"
13+
type: boolean
14+
default: true
15+
bailian_cli_runtime:
16+
description: "bailian-cli-runtime"
17+
type: boolean
18+
default: true
19+
bailian_cli_commands:
20+
description: "bailian-cli-commands"
21+
type: boolean
22+
default: true
23+
knowledge_studio_cli:
24+
description: "knowledge-studio-cli"
25+
type: boolean
26+
default: true
27+
bailian_kb_dsh:
28+
description: "bailian-kb-dsh"
29+
type: boolean
30+
default: true
31+
tags:
32+
description: "Tags to remove (spaces, commas or newlines). latest is protected."
33+
type: string
34+
required: true
35+
dry_run:
36+
description: "Preview only / 只预览,不删除"
37+
type: boolean
38+
default: true
39+
40+
permissions:
41+
contents: read
42+
43+
concurrency:
44+
group: cleanup-npm-tags
45+
cancel-in-progress: false
46+
47+
jobs:
48+
cleanup:
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 15
51+
permissions:
52+
contents: read
53+
id-token: write
54+
steps:
55+
- uses: actions/checkout@v6
56+
- uses: actions/setup-node@v6
57+
with:
58+
node-version: "24"
59+
registry-url: "https://registry.npmjs.org/"
60+
package-manager-cache: false
61+
- name: Install npm with OIDC dist-tag support
62+
run: npm install --global npm@11.21.0
63+
- name: Clean selected tags
64+
env:
65+
CLEANUP_CLI: ${{ inputs.bailian_cli }}
66+
CLEANUP_CORE: ${{ inputs.bailian_cli_core }}
67+
CLEANUP_RUNTIME: ${{ inputs.bailian_cli_runtime }}
68+
CLEANUP_COMMANDS: ${{ inputs.bailian_cli_commands }}
69+
CLEANUP_KSCLI: ${{ inputs.knowledge_studio_cli }}
70+
CLEANUP_DSH: ${{ inputs.bailian_kb_dsh }}
71+
CLEANUP_TAGS: ${{ inputs.tags }}
72+
CLEANUP_DRY_RUN: ${{ inputs.dry_run }}
73+
run: node tools/release/cleanup-npm-tags.mjs

‎docs/agents/publish.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
111111

112112
## CI 基础设施
113113

114+
### 批量清理 npm dist-tags
115+
116+
独立入口:GitHub Actions → **Clean npm tags**(`.github/workflows/cleanup-npm-tags.yml`)。
117+
118+
- 六个包复选框默认全选;`tags` 输入待删除的标签,支持空格、逗号或换行分隔。
119+
- `dry_run` 默认开启,只查询并预览。实际删除前先检查预览,再取消勾选;首次试跑只选 `bailian-cli`,标签填 `release-test`。
120+
- 先校验全部输入并读取所有所选包的标签,拒绝空选择、非法标签和 `latest`;标签不存在则跳过。
121+
- 实际删除串行调用 `npm dist-tag rm`,每次删除前检查标签是否仍指向预览版本,删除后查询确认;发生错误立即停止,已删除的标签不会自动恢复。日志及 Actions Summary 保留每条完成结果,可按同样输入重跑剩余项。
122+
- 此入口不发布版本、不构建二进制、不修改 CDN、不删除 npm 历史版本。
123+
- 所有目标包需要新增指向 `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。
124+
- 新增手动 workflow 需先进入默认分支才能触发。新 Trusted Publisher 有 48 小时首次验证期限;删除标签能否完成首次验证需要看 npm 页面实际状态,不能由清理成功推断 `Valid`。不要为验证自动发布 beta。
125+
114126
- **认证**:npm OIDC Trusted Publishing(无 token),需要 `id-token: write` 权限;OSS 通道复用同一 OIDC token 向 FC 证明身份(见上文「OSS 通道」)
115127
- **GitHub Release**:`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`(stable / channel 均需)
116128
- **Node 版本**:24(npm 11.5+ 才支持 OIDC token 交换)

‎tools/release/cleanup-npm-tags.mjs‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { spawn } from "node:child_process";
2+
import { appendFileSync } from "node:fs";
3+
import { pathToFileURL } from "node:url";
4+
5+
const REGISTRY = "https://registry.npmjs.org";
6+
const PACKAGE_INPUTS = [
7+
["CLEANUP_CLI", "bailian-cli"],
8+
["CLEANUP_CORE", "bailian-cli-core"],
9+
["CLEANUP_RUNTIME", "bailian-cli-runtime"],
10+
["CLEANUP_COMMANDS", "bailian-cli-commands"],
11+
["CLEANUP_KSCLI", "knowledge-studio-cli"],
12+
["CLEANUP_DSH", "bailian-kb-dsh"],
13+
];
14+
15+
function booleanInput(environment, name) {
16+
const value = environment[name] ?? "true";
17+
if (value !== "true" && value !== "false") throw new Error(`${name} must be true or false`);
18+
return value === "true";
19+
}
20+
21+
function validateSelection({ packages, tags, dryRun }) {
22+
const allowedPackages = new Set(PACKAGE_INPUTS.map(([, packageName]) => packageName));
23+
if (!packages.length || packages.some((packageName) => !allowedPackages.has(packageName)))
24+
throw new Error("Select at least one supported package");
25+
if (!tags.length) throw new Error("Enter at least one tag");
26+
for (const tag of tags) {
27+
if (tag === "latest") throw new Error("Deleting latest is forbidden");
28+
if (!/^[a-z][a-z0-9-]*$/.test(tag)) throw new Error(`Invalid tag: ${tag}`);
29+
}
30+
if (typeof dryRun !== "boolean") throw new Error("dryRun must be a boolean");
31+
}
32+
33+
export function parseCleanupInputs(environment = process.env) {
34+
const selection = {
35+
packages: PACKAGE_INPUTS.filter(([input]) => booleanInput(environment, input)).map(
36+
([, packageName]) => packageName,
37+
),
38+
tags: [...new Set((environment.CLEANUP_TAGS ?? "").split(/[\s,]+/).filter(Boolean))],
39+
dryRun: booleanInput(environment, "CLEANUP_DRY_RUN"),
40+
};
41+
validateSelection(selection);
42+
return selection;
43+
}
44+
45+
async function readRegistryTags(packageName) {
46+
const response = await fetch(`${REGISTRY}/-/package/${packageName}/dist-tags`, {
47+
cache: "no-store",
48+
signal: AbortSignal.timeout(30_000),
49+
});
50+
if (!response.ok) throw new Error(`Cannot read ${packageName} tags: HTTP ${response.status}`);
51+
const tags = await response.json();
52+
if (
53+
!tags ||
54+
typeof tags !== "object" ||
55+
Array.isArray(tags) ||
56+
Object.values(tags).some((version) => typeof version !== "string")
57+
)
58+
throw new Error(`Invalid registry tag response for ${packageName}`);
59+
return tags;
60+
}
61+
62+
function removeRegistryTag(packageName, tag) {
63+
// npm >=11.21 exchanges the workflow's OIDC identity; no long-lived token is supplied.
64+
return new Promise((resolve, reject) => {
65+
const child = spawn("npm", ["dist-tag", "rm", packageName, tag, "--registry", REGISTRY], {
66+
stdio: "inherit",
67+
shell: false,
68+
timeout: 60_000,
69+
});
70+
child.once("error", reject);
71+
child.once("exit", (code, signal) => {
72+
if (code === 0) resolve();
73+
else reject(new Error(`npm dist-tag rm ${packageName} ${tag} failed (${code ?? signal})`));
74+
});
75+
});
76+
}
77+
78+
function reportEntry({ packageName, tag, version, status }) {
79+
const message = `${status}: ${packageName} / ${tag} / ${version ?? "not present"}`;
80+
console.log(message);
81+
if (process.env.GITHUB_STEP_SUMMARY)
82+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `- ${message}\n`);
83+
}
84+
85+
export async function cleanupNpmTags(
86+
selection,
87+
{ readTags = readRegistryTags, removeTag = removeRegistryTag, report = reportEntry } = {},
88+
) {
89+
validateSelection(selection);
90+
const { packages, tags, dryRun } = selection;
91+
const plan = [];
92+
// Read every selected package before any deletion; request failures are not "tag absent".
93+
for (const packageName of packages) {
94+
const currentTags = await readTags(packageName);
95+
for (const tag of tags)
96+
plan.push({
97+
packageName,
98+
tag,
99+
version: Object.hasOwn(currentTags, tag) ? currentTags[tag] : null,
100+
});
101+
}
102+
103+
const results = [];
104+
for (const entry of plan) {
105+
let status = entry.version === null ? "absent" : "preview";
106+
if (entry.version !== null && !dryRun) {
107+
const currentTags = await readTags(entry.packageName);
108+
if (!Object.hasOwn(currentTags, entry.tag)) {
109+
status = "absent";
110+
} else {
111+
if (currentTags[entry.tag] !== entry.version)
112+
throw new Error(`Tag changed since preflight: ${entry.packageName} / ${entry.tag}`);
113+
await removeTag(entry.packageName, entry.tag);
114+
const afterTags = await readTags(entry.packageName);
115+
if (Object.hasOwn(afterTags, entry.tag))
116+
throw new Error(`Tag still exists after deletion: ${entry.packageName} / ${entry.tag}`);
117+
status = "deleted";
118+
}
119+
}
120+
const result = { ...entry, status };
121+
results.push(result);
122+
report(result);
123+
}
124+
return results;
125+
}
126+
127+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
128+
try {
129+
const selection = parseCleanupInputs();
130+
console.log(
131+
selection.dryRun ? "Preview only; no tags will be deleted." : "Deleting selected tags.",
132+
);
133+
await cleanupNpmTags(selection);
134+
} catch (error) {
135+
console.error(error instanceof Error ? error.message : String(error));
136+
process.exitCode = 1;
137+
}
138+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, test } from "vite-plus/test";
2+
import { cleanupNpmTags, parseCleanupInputs } from "./cleanup-npm-tags.mjs";
3+
4+
const onlyCli = {
5+
CLEANUP_CORE: "false",
6+
CLEANUP_RUNTIME: "false",
7+
CLEANUP_COMMANDS: "false",
8+
CLEANUP_KSCLI: "false",
9+
CLEANUP_DSH: "false",
10+
};
11+
12+
describe("npm tag cleanup", () => {
13+
test("defaults to previewing all six packages and accepts mixed tag separators", () => {
14+
expect(parseCleanupInputs({ CLEANUP_TAGS: "skill, harness\nknowledge skill" })).toEqual({
15+
packages: [
16+
"bailian-cli",
17+
"bailian-cli-core",
18+
"bailian-cli-runtime",
19+
"bailian-cli-commands",
20+
"knowledge-studio-cli",
21+
"bailian-kb-dsh",
22+
],
23+
tags: ["skill", "harness", "knowledge"],
24+
dryRun: true,
25+
});
26+
});
27+
28+
test("explicit false package selections limit an actual run to bailian-cli", () => {
29+
expect(
30+
parseCleanupInputs({ ...onlyCli, CLEANUP_TAGS: "release-test", CLEANUP_DRY_RUN: "false" }),
31+
).toEqual({ packages: ["bailian-cli"], tags: ["release-test"], dryRun: false });
32+
});
33+
34+
test.each(["", "latest", "skill latest", "--registry=evil", "skill;echo", "*", "1.2.3"])(
35+
"rejects unsafe or empty tag input %j before requests",
36+
(tags) => {
37+
expect(() => parseCleanupInputs({ CLEANUP_TAGS: tags })).toThrow();
38+
},
39+
);
40+
41+
test("rejects no selected package and malformed booleans", () => {
42+
expect(() =>
43+
parseCleanupInputs({ ...onlyCli, CLEANUP_CLI: "false", CLEANUP_TAGS: "skill" }),
44+
).toThrow(/package/i);
45+
expect(() => parseCleanupInputs({ CLEANUP_TAGS: "skill", CLEANUP_DRY_RUN: "no" })).toThrow();
46+
});
47+
48+
function registryFixture() {
49+
const registry = new Map([
50+
["bailian-cli", { latest: "2.3.0", skill: "0.0.0-beta-old" }],
51+
["bailian-cli-core", { latest: "2.3.0" }],
52+
]);
53+
return {
54+
registry,
55+
operations: {
56+
readTags: async (packageName) => ({ ...registry.get(packageName) }),
57+
removeTag: async (packageName, tag) => {
58+
delete registry.get(packageName)[tag];
59+
},
60+
report: () => {},
61+
},
62+
};
63+
}
64+
65+
test("preview reports present and absent tags without modifying the registry", async () => {
66+
const { registry, operations } = registryFixture();
67+
const result = await cleanupNpmTags(
68+
{ packages: ["bailian-cli", "bailian-cli-core"], tags: ["skill"], dryRun: true },
69+
operations,
70+
);
71+
expect(result).toEqual([
72+
{ packageName: "bailian-cli", tag: "skill", version: "0.0.0-beta-old", status: "preview" },
73+
{ packageName: "bailian-cli-core", tag: "skill", version: null, status: "absent" },
74+
]);
75+
expect(registry.get("bailian-cli")).toEqual({ latest: "2.3.0", skill: "0.0.0-beta-old" });
76+
});
77+
78+
test("actual cleanup removes only the selected tag and preserves latest", async () => {
79+
const { registry, operations } = registryFixture();
80+
const result = await cleanupNpmTags(
81+
{ packages: ["bailian-cli", "bailian-cli-core"], tags: ["skill"], dryRun: false },
82+
operations,
83+
);
84+
expect(result.map((entry) => entry.status)).toEqual(["deleted", "absent"]);
85+
expect(registry.get("bailian-cli")).toEqual({ latest: "2.3.0" });
86+
expect(registry.get("bailian-cli-core")).toEqual({ latest: "2.3.0" });
87+
});
88+
89+
test("an unreadable package aborts preflight before deleting from any package", async () => {
90+
const { registry, operations } = registryFixture();
91+
const readTags = operations.readTags;
92+
operations.readTags = async (packageName) => {
93+
if (packageName === "bailian-cli-core") throw new Error("registry unavailable");
94+
return readTags(packageName);
95+
};
96+
await expect(
97+
cleanupNpmTags(
98+
{ packages: ["bailian-cli", "bailian-cli-core"], tags: ["skill"], dryRun: false },
99+
operations,
100+
),
101+
).rejects.toThrow("registry unavailable");
102+
expect(registry.get("bailian-cli").skill).toBe("0.0.0-beta-old");
103+
});
104+
105+
test("failed deletion verification is not reported as success", async () => {
106+
const { operations } = registryFixture();
107+
operations.removeTag = async () => {};
108+
await expect(
109+
cleanupNpmTags({ packages: ["bailian-cli"], tags: ["skill"], dryRun: false }, operations),
110+
).rejects.toThrow(/still exists/i);
111+
});
112+
113+
test("direct callers cannot bypass latest and package protections", async () => {
114+
const { registry, operations } = registryFixture();
115+
await expect(
116+
cleanupNpmTags({ packages: ["bailian-cli"], tags: ["latest"], dryRun: false }, operations),
117+
).rejects.toThrow();
118+
await expect(
119+
cleanupNpmTags(
120+
{ packages: ["unrelated-package"], tags: ["skill"], dryRun: false },
121+
operations,
122+
),
123+
).rejects.toThrow();
124+
expect(registry.get("bailian-cli").latest).toBe("2.3.0");
125+
});
126+
});

0 commit comments

Comments
 (0)