Skip to content

Commit cdc8fb1

Browse files
committed
fix(release): retry published verification on npm lag
1 parent 49bf8bc commit cdc8fb1

2 files changed

Lines changed: 90 additions & 9 deletions

File tree

scripts/release/verify-published.test.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22

3-
import { selectPackageDirsForVerification } from "./verify-published";
3+
import { readPublishedPackageWithRetry, selectPackageDirsForVerification } from "./verify-published";
44
import type { ReleasePublishReport } from "./publish-bundles";
55

66
describe("Feature: published release verification scope", () => {
@@ -35,7 +35,7 @@ describe("Feature: published release verification scope", () => {
3535
).toEqual(["bundle/agenter"]);
3636
});
3737

38-
test("Scenario: Given a report contains no newly published packages When verification scope is resolved Then the verifier falls back to the full release order", () => {
38+
test("Scenario: Given a rerun report contains no newly published packages When verification scope is resolved Then the verifier keeps scope at the current attempt write set instead of widening to historical packages", () => {
3939
const report = {
4040
generatedAt: "2026-06-04T00:00:00.000Z",
4141
packages: [
@@ -48,9 +48,48 @@ describe("Feature: published release verification scope", () => {
4848
],
4949
} satisfies ReleasePublishReport;
5050

51-
expect(selectPackageDirsForVerification(report, ["bundle/@jixo/ghostty-native", "bundle/agenter"])).toEqual([
52-
"bundle/@jixo/ghostty-native",
53-
"bundle/agenter",
54-
]);
51+
expect(selectPackageDirsForVerification(report, ["bundle/@jixo/ghostty-native", "bundle/agenter"])).toEqual([]);
52+
});
53+
54+
test("Scenario: Given npm registry propagation lags a newly published package When release verification reads package metadata Then bounded E404 retries wait for the visible version instead of failing immediately", async () => {
55+
const calls: string[] = [];
56+
const sleeps: number[] = [];
57+
let attempts = 0;
58+
59+
const payload = await readPublishedPackageWithRetry("agenter", "0.0.12", {
60+
retryDelaysMs: [10, 20],
61+
runView: async () => {
62+
calls.push("runView");
63+
attempts += 1;
64+
if (attempts < 3) {
65+
throw new Error("npm view failed for agenter@0.0.12: npm error code E404\nnpm error 404 No match found for version 0.0.12");
66+
}
67+
return { version: "0.0.12" };
68+
},
69+
sleep: async (delayMs) => {
70+
sleeps.push(delayMs);
71+
},
72+
});
73+
74+
expect(payload.version).toBe("0.0.12");
75+
expect(calls).toHaveLength(3);
76+
expect(sleeps).toEqual([10, 20]);
77+
});
78+
79+
test("Scenario: Given npm view fails for a non-propagation reason When release verification reads package metadata Then the verifier fails immediately instead of masking the publish error", async () => {
80+
const sleeps: number[] = [];
81+
82+
await expect(
83+
readPublishedPackageWithRetry("agenter", "0.0.12", {
84+
retryDelaysMs: [10, 20],
85+
runView: async () => {
86+
throw new Error("npm view failed for agenter@0.0.12: npm error code E403");
87+
},
88+
sleep: async (delayMs) => {
89+
sleeps.push(delayMs);
90+
},
91+
}),
92+
).rejects.toThrow("npm error code E403");
93+
expect(sleeps).toEqual([]);
5594
});
5695
});

scripts/release/verify-published.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type NpmViewPayload = Partial<
99
>;
1010

1111
const repoRoot = resolve(import.meta.dir, "../..");
12+
const verifyPublishedRetryDelaysMs = [2_000, 4_000, 8_000, 16_000] as const;
1213

1314
const readPackageJson = async (packageDir: string): Promise<ReleasePackageJson> =>
1415
(await Bun.file(join(packageDir, "package.json")).json()) as ReleasePackageJson;
@@ -28,10 +29,12 @@ export const selectPackageDirsForVerification = (
2829
if (!report) {
2930
return packageDirs;
3031
}
31-
const publishedPackageDirs = report.packages
32+
// The publish report is the attempt-scoped truth. Verification only follows
33+
// package writes performed by this attempt and must not widen itself back to
34+
// historical packages on reruns where every package is already published.
35+
return report.packages
3236
.filter((entry) => entry.status === "published")
3337
.map((entry) => entry.packageDir);
34-
return publishedPackageDirs.length > 0 ? publishedPackageDirs : packageDirs;
3538
};
3639

3740
const normalizeRecord = (record: Record<string, string> | undefined): Record<string, string> | undefined => {
@@ -78,6 +81,45 @@ const runNpmView = async (name: string, version: string): Promise<NpmViewPayload
7881
return typeof payload === "string" ? { version: payload } : payload;
7982
};
8083

84+
const isRetryableNpmViewError = (error: unknown): error is Error => {
85+
if (!(error instanceof Error)) {
86+
return false;
87+
}
88+
return error.message.includes("npm error code E404") || error.message.includes("No match found for version");
89+
};
90+
91+
type ReadPublishedPackageOptions = {
92+
readonly retryDelaysMs?: readonly number[];
93+
readonly runView?: typeof runNpmView;
94+
readonly sleep?: (delayMs: number) => Promise<void>;
95+
};
96+
97+
export const readPublishedPackageWithRetry = async (
98+
name: string,
99+
version: string,
100+
options: ReadPublishedPackageOptions = {},
101+
): Promise<NpmViewPayload> => {
102+
const retryDelaysMs = options.retryDelaysMs ?? verifyPublishedRetryDelaysMs;
103+
const runView = options.runView ?? runNpmView;
104+
const sleep = options.sleep ?? Bun.sleep;
105+
106+
for (const [attemptIndex, retryDelayMs] of retryDelaysMs.entries()) {
107+
try {
108+
return await runView(name, version);
109+
} catch (error) {
110+
if (!isRetryableNpmViewError(error)) {
111+
throw error;
112+
}
113+
console.warn(
114+
`npm view for ${name}@${version} is not visible yet; retrying in ${retryDelayMs}ms (${attemptIndex + 1}/${retryDelaysMs.length})`,
115+
);
116+
await sleep(retryDelayMs);
117+
}
118+
}
119+
120+
return await runView(name, version);
121+
};
122+
81123
const verifyPackage = async (packageDir: string): Promise<void> => {
82124
const absolutePackageDir = join(repoRoot, packageDir);
83125
const packageJsonPath = join(absolutePackageDir, "package.json");
@@ -86,7 +128,7 @@ const verifyPackage = async (packageDir: string): Promise<void> => {
86128
}
87129

88130
const expected = await readPackageJson(absolutePackageDir);
89-
const actual = await runNpmView(expected.name, expected.version);
131+
const actual = await readPublishedPackageWithRetry(expected.name, expected.version);
90132
if (actual.version !== expected.version) {
91133
throw new Error(
92134
`${expected.name} version mismatch: expected ${expected.version}, got ${actual.version ?? "<missing>"}`,

0 commit comments

Comments
 (0)