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
4 changes: 2 additions & 2 deletions docs/agents/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
- 六个包复选框默认全选;`tags` 输入待删除的标签,支持空格、逗号或换行分隔。
- `dry_run` 默认开启,只查询并预览。实际删除前先检查预览,再取消勾选;首次试跑只选 `bailian-cli`,标签填 `release-test`。
- 先校验全部输入并读取所有所选包的标签,拒绝空选择、非法标签和 `latest`;标签不存在则跳过。
- 实际删除串行调用 `npm dist-tag rm`,每次删除前检查标签是否仍指向预览版本,删除后查询确认;发生错误立即停止,已删除的标签不会自动恢复。日志及 Actions Summary 保留每条完成结果,可按同样输入重跑剩余项。
- 实际删除串行调用 `npm dist-tag rm`,每次删除前检查标签是否仍指向预览版本,删除后最多查询 5 次确认(间隔 1、2、4、8 秒),容忍 registry 短暂返回旧数据,删除命令只执行一次;发生错误立即停止,已删除的标签不会自动恢复。日志及 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。
- 新增手动 workflow 需先进入默认分支才能触发。新 Trusted Publisher 有 48 小时首次验证期限;2026-10-15 首次试跑仅删除 `bailian-cli` 的 `release-test`,npm 页面确认该清理连接已变为 `Valid`,没有发布新版本。其他连接仍需查看各包页面确认,不能由清理成功推断 `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 均需)
Expand Down
22 changes: 18 additions & 4 deletions tools/release/cleanup-npm-tags.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { setTimeout as waitForDelay } from "node:timers/promises";
import { pathToFileURL } from "node:url";

const REGISTRY = "https://registry.npmjs.org";
Expand Down Expand Up @@ -75,6 +76,16 @@ function removeRegistryTag(packageName, tag) {
});
}

async function verifyTagRemoved(packageName, tag, readTags, wait) {
// Registry reads can lag a successful DELETE. Retry reads only, never the mutation.
for (let attempt = 0; attempt < 5; attempt += 1) {
const currentTags = await readTags(packageName);
if (!Object.hasOwn(currentTags, tag)) return;
if (attempt < 4) await wait(1000 * 2 ** attempt);
}
throw new Error(`Tag still exists after deletion: ${packageName} / ${tag}`);
}

function reportEntry({ packageName, tag, version, status }) {
const message = `${status}: ${packageName} / ${tag} / ${version ?? "not present"}`;
console.log(message);
Expand All @@ -84,7 +95,12 @@ function reportEntry({ packageName, tag, version, status }) {

export async function cleanupNpmTags(
selection,
{ readTags = readRegistryTags, removeTag = removeRegistryTag, report = reportEntry } = {},
{
readTags = readRegistryTags,
removeTag = removeRegistryTag,
report = reportEntry,
wait = waitForDelay,
} = {},
) {
validateSelection(selection);
const { packages, tags, dryRun } = selection;
Expand All @@ -111,9 +127,7 @@ export async function cleanupNpmTags(
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}`);
await verifyTagRemoved(entry.packageName, entry.tag, readTags, wait);
status = "deleted";
}
}
Expand Down
35 changes: 35 additions & 0 deletions tools/release/cleanup-npm-tags.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("npm tag cleanup", () => {
delete registry.get(packageName)[tag];
},
report: () => {},
wait: async () => {},
},
};
}
Expand Down Expand Up @@ -86,6 +87,35 @@ describe("npm tag cleanup", () => {
expect(registry.get("bailian-cli-core")).toEqual({ latest: "2.3.0" });
});

test("waits for stale registry reads without repeating deletion", async () => {
const { registry, operations } = registryFixture();
const originalReadTags = operations.readTags;
const originalRemoveTag = operations.removeTag;
const staleTags = { ...registry.get("bailian-cli") };
let deletionCount = 0;
let staleReads = 0;
operations.removeTag = async (packageName, tag) => {
deletionCount += 1;
await originalRemoveTag(packageName, tag);
staleReads = 2;
};
operations.readTags = async (packageName) => {
if (staleReads > 0) {
staleReads -= 1;
return { ...staleTags };
}
return originalReadTags(packageName);
};
const results = await cleanupNpmTags(
{ packages: ["bailian-cli"], tags: ["skill"], dryRun: false },
operations,
);
expect(results[0].status).toBe("deleted");
expect(deletionCount).toBe(1);
expect(staleReads).toBe(0);
expect(registry.get("bailian-cli")).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;
Expand All @@ -105,9 +135,14 @@ describe("npm tag cleanup", () => {
test("failed deletion verification is not reported as success", async () => {
const { operations } = registryFixture();
operations.removeTag = async () => {};
const delays = [];
operations.wait = async (delay) => {
delays.push(delay);
};
await expect(
cleanupNpmTags({ packages: ["bailian-cli"], tags: ["skill"], dryRun: false }, operations),
).rejects.toThrow(/still exists/i);
expect(delays).toEqual([1000, 2000, 4000, 8000]);
});

test("direct callers cannot bypass latest and package protections", async () => {
Expand Down
Loading