Skip to content

Commit c16aeaf

Browse files
Merge pull request #257 from modelstudioai/fix/npm-tag-propagation
fix: avoid false failures after npm tag deletion
2 parents 5ec9f3b + 29be720 commit c16aeaf

3 files changed

Lines changed: 55 additions & 6 deletions

File tree

‎docs/agents/publish.md‎

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

126126
- **认证**:npm OIDC Trusted Publishing(无 token),需要 `id-token: write` 权限;OSS 通道复用同一 OIDC token 向 FC 证明身份(见上文「OSS 通道」)
127127
- **GitHub Release**:`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`(stable / channel 均需)

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from "node:child_process";
22
import { appendFileSync } from "node:fs";
3+
import { setTimeout as waitForDelay } from "node:timers/promises";
34
import { pathToFileURL } from "node:url";
45

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

79+
async function verifyTagRemoved(packageName, tag, readTags, wait) {
80+
// Registry reads can lag a successful DELETE. Retry reads only, never the mutation.
81+
for (let attempt = 0; attempt < 5; attempt += 1) {
82+
const currentTags = await readTags(packageName);
83+
if (!Object.hasOwn(currentTags, tag)) return;
84+
if (attempt < 4) await wait(1000 * 2 ** attempt);
85+
}
86+
throw new Error(`Tag still exists after deletion: ${packageName} / ${tag}`);
87+
}
88+
7889
function reportEntry({ packageName, tag, version, status }) {
7990
const message = `${status}: ${packageName} / ${tag} / ${version ?? "not present"}`;
8091
console.log(message);
@@ -84,7 +95,12 @@ function reportEntry({ packageName, tag, version, status }) {
8495

8596
export async function cleanupNpmTags(
8697
selection,
87-
{ readTags = readRegistryTags, removeTag = removeRegistryTag, report = reportEntry } = {},
98+
{
99+
readTags = readRegistryTags,
100+
removeTag = removeRegistryTag,
101+
report = reportEntry,
102+
wait = waitForDelay,
103+
} = {},
88104
) {
89105
validateSelection(selection);
90106
const { packages, tags, dryRun } = selection;
@@ -111,9 +127,7 @@ export async function cleanupNpmTags(
111127
if (currentTags[entry.tag] !== entry.version)
112128
throw new Error(`Tag changed since preflight: ${entry.packageName} / ${entry.tag}`);
113129
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}`);
130+
await verifyTagRemoved(entry.packageName, entry.tag, readTags, wait);
117131
status = "deleted";
118132
}
119133
}

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe("npm tag cleanup", () => {
5858
delete registry.get(packageName)[tag];
5959
},
6060
report: () => {},
61+
wait: async () => {},
6162
},
6263
};
6364
}
@@ -86,6 +87,35 @@ describe("npm tag cleanup", () => {
8687
expect(registry.get("bailian-cli-core")).toEqual({ latest: "2.3.0" });
8788
});
8889

90+
test("waits for stale registry reads without repeating deletion", async () => {
91+
const { registry, operations } = registryFixture();
92+
const originalReadTags = operations.readTags;
93+
const originalRemoveTag = operations.removeTag;
94+
const staleTags = { ...registry.get("bailian-cli") };
95+
let deletionCount = 0;
96+
let staleReads = 0;
97+
operations.removeTag = async (packageName, tag) => {
98+
deletionCount += 1;
99+
await originalRemoveTag(packageName, tag);
100+
staleReads = 2;
101+
};
102+
operations.readTags = async (packageName) => {
103+
if (staleReads > 0) {
104+
staleReads -= 1;
105+
return { ...staleTags };
106+
}
107+
return originalReadTags(packageName);
108+
};
109+
const results = await cleanupNpmTags(
110+
{ packages: ["bailian-cli"], tags: ["skill"], dryRun: false },
111+
operations,
112+
);
113+
expect(results[0].status).toBe("deleted");
114+
expect(deletionCount).toBe(1);
115+
expect(staleReads).toBe(0);
116+
expect(registry.get("bailian-cli")).toEqual({ latest: "2.3.0" });
117+
});
118+
89119
test("an unreadable package aborts preflight before deleting from any package", async () => {
90120
const { registry, operations } = registryFixture();
91121
const readTags = operations.readTags;
@@ -105,9 +135,14 @@ describe("npm tag cleanup", () => {
105135
test("failed deletion verification is not reported as success", async () => {
106136
const { operations } = registryFixture();
107137
operations.removeTag = async () => {};
138+
const delays = [];
139+
operations.wait = async (delay) => {
140+
delays.push(delay);
141+
};
108142
await expect(
109143
cleanupNpmTags({ packages: ["bailian-cli"], tags: ["skill"], dryRun: false }, operations),
110144
).rejects.toThrow(/still exists/i);
145+
expect(delays).toEqual([1000, 2000, 4000, 8000]);
111146
});
112147

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

0 commit comments

Comments
 (0)