diff --git a/docs/agents/publish.md b/docs/agents/publish.md index 0ef22de0..336a0be0 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -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 均需) diff --git a/tools/release/cleanup-npm-tags.mjs b/tools/release/cleanup-npm-tags.mjs index cf78b47f..b109e282 100644 --- a/tools/release/cleanup-npm-tags.mjs +++ b/tools/release/cleanup-npm-tags.mjs @@ -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"; @@ -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); @@ -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; @@ -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"; } } diff --git a/tools/release/cleanup-npm-tags.test.mjs b/tools/release/cleanup-npm-tags.test.mjs index 891b0757..da21a504 100644 --- a/tools/release/cleanup-npm-tags.test.mjs +++ b/tools/release/cleanup-npm-tags.test.mjs @@ -58,6 +58,7 @@ describe("npm tag cleanup", () => { delete registry.get(packageName)[tag]; }, report: () => {}, + wait: async () => {}, }, }; } @@ -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; @@ -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 () => {