Skip to content

Commit 812e63c

Browse files
committed
fix(ci): retry cf-typegen's drift check once before reporting stale
wrangler types was observed to occasionally emit a differently-shaped ProcessEnv union on a cold invocation (freshly restored node_modules, no prior local wrangler state -- CI's steady state on every run) versus a warm one, for the same wrangler.jsonc and the same wrangler version. This is external-tool flakiness, not a defect in this script's own reformatting logic, and it was intermittently failing CI on an otherwise-correct committed file. One retry absorbs the transient case without masking a real drift: a genuinely stale file mismatches on both the first attempt and the immediately-following retry.
1 parent ff442f9 commit 812e63c

2 files changed

Lines changed: 107 additions & 14 deletions

File tree

scripts/gen-cf-typegen.mjs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,29 @@ export function formatCfTypegenOutput(source) {
3737
return stripped.replace(PICK_RE, `Pick<Cloudflare.Env,\n${lines}\n\t>>`);
3838
}
3939

40-
export function genCfTypegen({ check = false } = {}) {
41-
const target = check ? TEMP_PATH : OUTPUT_PATH;
40+
function generateOnce(target) {
4241
try {
4342
execFileSync("wrangler", ["types", target], { stdio: "inherit" });
44-
const generated = formatCfTypegenOutput(readFileSync(target, "utf8"));
45-
if (!check) {
46-
writeFileSync(OUTPUT_PATH, generated);
47-
return { changed: undefined };
48-
}
49-
const current = readFileSync(OUTPUT_PATH, "utf8");
50-
return { changed: current !== generated };
43+
return formatCfTypegenOutput(readFileSync(target, "utf8"));
5144
} finally {
52-
if (check && existsSync(TEMP_PATH)) unlinkSync(TEMP_PATH);
45+
if (target !== OUTPUT_PATH && existsSync(target)) unlinkSync(target);
46+
}
47+
}
48+
49+
export function genCfTypegen({ check = false } = {}) {
50+
if (!check) {
51+
writeFileSync(OUTPUT_PATH, generateOnce(OUTPUT_PATH));
52+
return { changed: undefined };
5353
}
54+
const current = readFileSync(OUTPUT_PATH, "utf8");
55+
// `wrangler types` is observed to occasionally emit a differently-shaped ProcessEnv union on a "cold"
56+
// invocation (freshly restored node_modules, no prior local wrangler state -- exactly CI's steady state on
57+
// every run) versus a "warm" one, for the SAME wrangler.jsonc and the SAME wrangler version -- an external
58+
// tool flakiness, not a defect in this repo's own reformatting logic (which is pure and independently
59+
// regression-tested via a real `git merge-file`). One retry absorbs that without masking a REAL drift: a
60+
// genuinely stale committed file mismatches on both the first AND the immediately-following attempt.
61+
if (current === generateOnce(TEMP_PATH)) return { changed: false };
62+
return { changed: current !== generateOnce(TEMP_PATH) };
5463
}
5564

5665
function main(argv) {

test/unit/ci-cf-typegen.test.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
import { execFileSync } from "node:child_process";
2-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
32
import { tmpdir } from "node:os";
43
import { join } from "node:path";
5-
import { describe, expect, it } from "vitest";
6-
import { formatCfTypegenOutput } from "../../scripts/gen-cf-typegen.mjs";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { formatCfTypegenOutput, genCfTypegen, OUTPUT_PATH } from "../../scripts/gen-cf-typegen.mjs";
6+
7+
// Only the `wrangler` invocation is ever faked below (via mockImplementation in the retry tests); every other
8+
// call (the `git merge-file` regression tests further down) passes straight through to the real execFileSync,
9+
// so this mock changes nothing about those tests.
10+
vi.mock("node:child_process", async (importOriginal) => {
11+
const actual = await importOriginal<typeof import("node:child_process")>();
12+
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
13+
});
14+
import { execFileSync } from "node:child_process";
715

816
// #1667-followup: wrangler's own `wrangler types` output packs every declared env var into ONE long
917
// single-line `Pick<Cloudflare.Env, "A" | "B" | ...>>` union. Two independent PRs that each add a DIFFERENT
@@ -105,3 +113,79 @@ describe("gen-cf-typegen: formatCfTypegenOutput", () => {
105113
}
106114
});
107115
});
116+
117+
// `wrangler types` is observed to occasionally emit a differently-shaped ProcessEnv union on a "cold"
118+
// invocation vs a "warm" one for the SAME wrangler.jsonc and wrangler version -- external-tool flakiness, not
119+
// a defect in this repo's own reformatting logic. genCfTypegen's check path retries once before reporting
120+
// staleness, so a transient one-off mismatch self-heals instead of failing CI.
121+
describe("gen-cf-typegen: genCfTypegen retry behavior", () => {
122+
const originalCwd = process.cwd();
123+
let dir: string;
124+
125+
beforeEach(() => {
126+
dir = mkdtempSync(join(tmpdir(), "cf-typegen-retry-"));
127+
process.chdir(dir);
128+
});
129+
130+
afterEach(() => {
131+
process.chdir(originalCwd);
132+
rmSync(dir, { recursive: true, force: true });
133+
vi.mocked(execFileSync).mockClear();
134+
});
135+
136+
function mockWrangler(outputs: string[]) {
137+
let call = 0;
138+
vi.mocked(execFileSync).mockImplementation(((cmd: string, args: string[]) => {
139+
if (cmd !== "wrangler") throw new Error(`unexpected command: ${cmd}`);
140+
const target = args[1]!;
141+
const output = outputs[Math.min(call, outputs.length - 1)]!;
142+
call += 1;
143+
writeFileSync(target, output);
144+
return Buffer.from("");
145+
}) as typeof execFileSync);
146+
}
147+
148+
it("REGRESSION: a mismatch on the first wrangler invocation self-heals when the retry matches (transient tool flakiness)", () => {
149+
const committed = formatCfTypegenOutput(rawFixture(["ALPHA", "MID"]));
150+
writeFileSync(OUTPUT_PATH, committed);
151+
// First call emits an extra var (simulating a cold-vs-warm wrangler discrepancy); the retry matches.
152+
mockWrangler([rawFixture(["ALPHA", "MID", "STRAY"]), rawFixture(["ALPHA", "MID"])]);
153+
154+
const { changed } = genCfTypegen({ check: true });
155+
156+
expect(changed).toBe(false);
157+
expect(execFileSync).toHaveBeenCalledTimes(2);
158+
});
159+
160+
it("still reports genuine drift when BOTH the first attempt and the retry mismatch the committed file", () => {
161+
const committed = formatCfTypegenOutput(rawFixture(["ALPHA", "MID"]));
162+
writeFileSync(OUTPUT_PATH, committed);
163+
mockWrangler([rawFixture(["ALPHA", "MID", "REAL_NEW_VAR"])]);
164+
165+
const { changed } = genCfTypegen({ check: true });
166+
167+
expect(changed).toBe(true);
168+
expect(execFileSync).toHaveBeenCalledTimes(2);
169+
});
170+
171+
it("does not retry (single wrangler invocation) when the committed file is already up to date", () => {
172+
const committed = formatCfTypegenOutput(rawFixture(["ALPHA", "MID"]));
173+
writeFileSync(OUTPUT_PATH, committed);
174+
mockWrangler([rawFixture(["ALPHA", "MID"])]);
175+
176+
const { changed } = genCfTypegen({ check: true });
177+
178+
expect(changed).toBe(false);
179+
expect(execFileSync).toHaveBeenCalledTimes(1);
180+
});
181+
182+
it("regenerate mode (check: false) writes the formatted output directly to worker-configuration.d.ts", () => {
183+
mockWrangler([rawFixture(["ZEBRA", "ALPHA"])]);
184+
185+
const { changed } = genCfTypegen({ check: false });
186+
187+
expect(changed).toBeUndefined();
188+
expect(readFileSync(OUTPUT_PATH, "utf8")).toBe(formatCfTypegenOutput(rawFixture(["ZEBRA", "ALPHA"])));
189+
expect(execFileSync).toHaveBeenCalledTimes(1);
190+
});
191+
});

0 commit comments

Comments
 (0)