diff --git a/.gitignore b/.gitignore index 8d2d687772..e786709023 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ coverage/ site/.vitepress/cache/ !migrations/*.sql apps/gittensory-ui/public/downloads/gittensory-extension.zip +.worker-configuration.gen-check.d.ts diff --git a/package.json b/package.json index 162faae8da..1efaf04e12 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "command-reference:check": "node scripts/gen-command-reference.mjs --check", "selfhost:validate-observability": "node scripts/validate-observability-configs.mjs", "selfhost:config-lint": "tsx scripts/gittensory-config-lint.ts", - "cf-typegen": "wrangler types && perl -pi -e 's/[[:blank:]]+$//' worker-configuration.d.ts", - "cf-typegen:check": "wrangler types --check", + "cf-typegen": "node scripts/gen-cf-typegen.mjs", + "cf-typegen:check": "node scripts/gen-cf-typegen.mjs --check", "db:migrate:local": "wrangler d1 migrations apply gittensory --local", "db:migrate:remote": "wrangler d1 migrations apply gittensory --remote", "drizzle:generate": "drizzle-kit generate", diff --git a/scripts/gen-cf-typegen.d.mts b/scripts/gen-cf-typegen.d.mts new file mode 100644 index 0000000000..924b38f0c8 --- /dev/null +++ b/scripts/gen-cf-typegen.d.mts @@ -0,0 +1,5 @@ +export const OUTPUT_PATH: string; + +export function formatCfTypegenOutput(source: string): string; + +export function genCfTypegen(options?: { check?: boolean }): { changed: boolean | undefined }; diff --git a/scripts/gen-cf-typegen.mjs b/scripts/gen-cf-typegen.mjs new file mode 100644 index 0000000000..21ae493218 --- /dev/null +++ b/scripts/gen-cf-typegen.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +// Cloudflare's own `wrangler types` output packs every declared env var into ONE long single-line +// `Pick>` union (the generated ProcessEnv interface). Two independent PRs +// that each add a DIFFERENT new var to wrangler.jsonc's `vars` regenerate that SAME line with their own +// insertion -- git's line-based 3-way merge sees "the same line changed differently in both branches" and +// reports a conflict on worker-configuration.d.ts, even though the two additions are logically unrelated +// (hit repeatedly across the #1667 incident-followup PRs). This wrapper still regenerates via the real +// `wrangler types` CLI (so it can never drift from wrangler's own output), then reformats JUST that one +// union onto one sorted entry per line -- two PRs adding different vars now land on different, non-adjacent +// lines and merge cleanly like every other interface body in this file already does. +export const OUTPUT_PATH = "worker-configuration.d.ts"; +const TEMP_PATH = ".worker-configuration.gen-check.d.ts"; +const PICK_RE = /Pick>/; + +/** Pure: strip trailing whitespace (wrangler's own raw output convention this repo has always normalized), + * normalize wrangler's own "Generated by ... running `wrangler types [path]`" header comment (it echoes + * whatever path argument was passed, which check mode intentionally sets to a different temp path than + * the real OUTPUT_PATH — without this, check mode would always report false staleness on that line alone), + * then reformat the single-line ProcessEnv Pick union into one sorted entry per line. */ +export function formatCfTypegenOutput(source) { + const stripped = source + .replace(/[ \t]+$/gm, "") + .replace(/(Generated by Wrangler by running `wrangler types)[^`]*`/, "$1`"); + const match = stripped.match(PICK_RE); + if (!match) { + throw new Error(`Could not find the ProcessEnv Pick union in the generated output to reformat.`); + } + const keys = match[1] + .split(" | ") + .map((quoted) => quoted.slice(1, -1)) + .sort((a, b) => a.localeCompare(b)); + const lines = keys.map((key) => `\t\t| "${key}"`).join("\n"); + return stripped.replace(PICK_RE, `Pick>`); +} + +export function genCfTypegen({ check = false } = {}) { + const target = check ? TEMP_PATH : OUTPUT_PATH; + try { + execFileSync("wrangler", ["types", target], { stdio: "inherit" }); + const generated = formatCfTypegenOutput(readFileSync(target, "utf8")); + if (!check) { + writeFileSync(OUTPUT_PATH, generated); + return { changed: undefined }; + } + const current = readFileSync(OUTPUT_PATH, "utf8"); + return { changed: current !== generated }; + } finally { + if (check && existsSync(TEMP_PATH)) unlinkSync(TEMP_PATH); + } +} + +function main(argv) { + const check = argv.includes("--check"); + const { changed } = genCfTypegen({ check }); + if (check) { + if (changed) { + process.stderr.write(`gen-cf-typegen: ${OUTPUT_PATH} is stale; run npm run cf-typegen.\n`); + process.exit(1); + } + process.stdout.write(`gen-cf-typegen: ${OUTPUT_PATH} is up to date.\n`); + return; + } + process.stdout.write(`gen-cf-typegen: wrote ${OUTPUT_PATH}.\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(process.argv.slice(2)); +} diff --git a/test/unit/ci-cf-typegen-check.test.ts b/test/unit/ci-cf-typegen-check.test.ts index 7d22c85189..fa222e765d 100644 --- a/test/unit/ci-cf-typegen-check.test.ts +++ b/test/unit/ci-cf-typegen-check.test.ts @@ -30,7 +30,7 @@ describe("cf-typegen staleness guard (#2557)", () => { const pkg = record(JSON.parse(readFileSync("package.json", "utf8")), "package.json"); const scripts = record(pkg.scripts, "package.json.scripts"); - expect(scripts["cf-typegen:check"]).toBe("wrangler types --check"); + expect(scripts["cf-typegen:check"]).toBe("node scripts/gen-cf-typegen.mjs --check"); expect(String(scripts["test:ci"])).toContain("npm run cf-typegen:check"); // Must run before typecheck, mirroring db:migrations:check's position -- a drift-check failure should // surface before the more expensive type/test/build steps run. diff --git a/test/unit/ci-cf-typegen.test.ts b/test/unit/ci-cf-typegen.test.ts new file mode 100644 index 0000000000..1c78a34c7f --- /dev/null +++ b/test/unit/ci-cf-typegen.test.ts @@ -0,0 +1,107 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { formatCfTypegenOutput } from "../../scripts/gen-cf-typegen.mjs"; + +// #1667-followup: wrangler's own `wrangler types` output packs every declared env var into ONE long +// single-line `Pick>` union. Two independent PRs that each add a DIFFERENT +// new var regenerate that SAME line with their own insertion -- git's line-based 3-way merge sees "the same +// line changed differently in both branches" and reports a real merge conflict on worker-configuration.d.ts, +// even though the two additions are logically unrelated. This hit several same-day PRs in a row. The fix: +// reformat that one union onto one sorted entry per line, so two independent additions land on different, +// non-adjacent lines and merge cleanly like every other interface body in this file already does. +function rawFixture(vars: string[]): string { + const union = vars.map((v) => `"${v}"`).join(" | "); + return [ + "/* eslint-disable */", + "// Generated by Wrangler by running `wrangler types` (hash: deadbeef)", + "// Runtime types generated with workerd@1.0.0 2026-01-01 nodejs_compat", + "interface Env {}", + "declare namespace NodeJS {", + `\tinterface ProcessEnv extends StringifyValues> {}`, + "}", + "", + ].join("\n"); +} + +describe("gen-cf-typegen: formatCfTypegenOutput", () => { + it("reformats the single-line Pick union into one sorted entry per line", () => { + const out = formatCfTypegenOutput(rawFixture(["ZEBRA", "ALPHA", "MID"])); + expect(out).toContain('Pick>'); + }); + + it("sorts regardless of the original declaration order", () => { + const outAsc = formatCfTypegenOutput(rawFixture(["A", "B", "C"])); + const outDesc = formatCfTypegenOutput(rawFixture(["C", "B", "A"])); + expect(outAsc).toBe(outDesc); + }); + + it("normalizes the wrangler header's echoed path argument so a different invocation path does not read as a content change", () => { + const bare = formatCfTypegenOutput(rawFixture(["A"])); + const withPath = formatCfTypegenOutput(rawFixture(["A"]).replace("wrangler types` (hash", "wrangler types .some-temp-path.d.ts` (hash")); + expect(bare).toBe(withPath); + }); + + it("strips trailing whitespace from every line", () => { + const withTrailingWhitespace = rawFixture(["A"]).replace("interface Env {}", "interface Env {} "); + const out = formatCfTypegenOutput(withTrailingWhitespace); + expect(out).not.toMatch(/ +\n/); + expect(out).toContain("interface Env {}\n"); + }); + + it("throws when the ProcessEnv Pick union is not found", () => { + expect(() => formatCfTypegenOutput("interface Env {}\n")).toThrow(/Could not find the ProcessEnv/); + }); + + it("REGRESSION: two independent single-var additions no longer conflict under a real git 3-way merge", () => { + const dir = mkdtempSync(join(tmpdir(), "cf-typegen-merge-")); + try { + const base = formatCfTypegenOutput(rawFixture(["ALPHA", "MID", "ZEBRA"])); + const ours = formatCfTypegenOutput(rawFixture(["ALPHA", "BRAVO", "MID", "ZEBRA"])); // adds BRAVO + const theirs = formatCfTypegenOutput(rawFixture(["ALPHA", "MID", "YANKEE", "ZEBRA"])); // adds YANKEE + const basePath = join(dir, "base.d.ts"); + const oursPath = join(dir, "ours.d.ts"); + const theirsPath = join(dir, "theirs.d.ts"); + writeFileSync(basePath, base); + writeFileSync(oursPath, ours); + writeFileSync(theirsPath, theirs); + + const merged = execFileSync("git", ["merge-file", "-p", oursPath, basePath, theirsPath], { encoding: "utf8" }); + expect(merged).not.toContain("<<<<<<<"); + expect(merged).toContain('| "BRAVO"'); + expect(merged).toContain('| "YANKEE"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("BASELINE: the SAME two additions DO conflict on the original unformatted single-line union", () => { + const dir = mkdtempSync(join(tmpdir(), "cf-typegen-merge-baseline-")); + try { + const base = rawFixture(["ALPHA", "MID", "ZEBRA"]); + const ours = rawFixture(["ALPHA", "BRAVO", "MID", "ZEBRA"]); + const theirs = rawFixture(["ALPHA", "MID", "YANKEE", "ZEBRA"]); + const basePath = join(dir, "base.d.ts"); + const oursPath = join(dir, "ours.d.ts"); + const theirsPath = join(dir, "theirs.d.ts"); + writeFileSync(basePath, base); + writeFileSync(oursPath, ours); + writeFileSync(theirsPath, theirs); + + let conflicted = false; + let merged = ""; + try { + merged = execFileSync("git", ["merge-file", "-p", oursPath, basePath, theirsPath], { encoding: "utf8" }); + } catch (error) { + conflicted = true; + merged = String((error as { stdout?: string }).stdout ?? ""); + } + expect(conflicted).toBe(true); + expect(merged).toContain("<<<<<<<"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 163cc474d3..24ad833367 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -56,7 +56,46 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types