diff --git a/tools/renovate/config.json5 b/tools/renovate/config.json5 index e2e8a5f2d..8682ffcd6 100644 --- a/tools/renovate/config.json5 +++ b/tools/renovate/config.json5 @@ -441,6 +441,32 @@ matchDepNames: ["go"], enabled: false, }, + { + // ── wails/v3: hold below v3.1 (GTK4-migration floor, RIG-2818/RIG-2819) ── + // The GTK4 migration record freezes a hard "Never v3.1" floor + // (docs/designs/product/compass-gtk4-migration/design.md §Global Constraints + // "Wails floor"): wails v3.1 drops the legacy GTK3 build tag, so bumping to it + // before the GTK4 flip (RIG-2819) is proven would strand the app with no + // working native shell. go/go.mod pins a v3.0.0 prerelease today; Renovate would + // auto-open a v3.1.x bump the moment upstream ships it, violating the floor + // silently. Cap it here until the flip lands and every wails bump is gated by + // the gtk4-e2e lane. + // + // REGEX, not a semver range: gomod uses `semver` versioning, whose node-semver + // ranges admit a prerelease ONLY when a comparator shares its exact + // major.minor.patch tuple — so `< 3.1.0-0` would REJECT the current v3.0.0 + // prerelease pin (its only comparator sits at tuple 3.1.0), opening zero PRs + // and freezing the pin. A slash-delimited regex is matched against the raw + // version string instead, sidestepping prerelease-range semantics entirely + // (mirrors the postgres-stack `/^18$/` rule above). `^v?3\.0\.` admits + // v3.0.0-beta.N and any future v3.0.x, rejects v3.1.x and v4+. config.test.ts + // replays it against the live go.mod pin plus the v3.0.x / v3.1.x / v4.0.0 + // boundaries. Remove this rule + its + // guard once RIG-2819's GTK4 flip is merged. + matchManagers: ["gomod"], + matchDepNames: ["github.com/wailsapp/wails/v3"], + allowedVersions: "/^v?3\\.0\\./", + }, { // ── bunfig soak exemption for the bun types packages ── // bunfig.toml `minimumReleaseAgeExcludes` exempts `@types/bun` and `bun-types` @@ -645,6 +671,11 @@ "tools/toolchain/versions/node.nix", "tools/toolchain/versions/moon.nix", "guest-image/default.nix", + // flake.nix carries the SAME Go vendorHash as guest-image/default.nix + // (both buildGoModule + proxyVendor over go/); refresh-fod-hashes.ts mirrors + // the recomputed value into it, so it must be committable too or a gomod + // bump lands with flake.nix's vendorHash stale → `nix flake check` red. + "flake.nix", "agent-image/entrypoint.nix", ], executionMode: "branch", diff --git a/tools/renovate/config.test.ts b/tools/renovate/config.test.ts index bf67bf3ee..88a16f86b 100644 --- a/tools/renovate/config.test.ts +++ b/tools/renovate/config.test.ts @@ -281,10 +281,16 @@ describe("tools/renovate FOD-hash refresh wiring (PR #579)", () => { expect(topLevel?.executionMode).toBe("branch"); }); - test("the top-level task commits BOTH FOD files (fileFilters cover them)", () => { + test("the top-level task commits ALL THREE Go/bun FOD files (fileFilters cover them)", () => { // gomod branches + bun/npm-first branches inherit this slot; it must be able - // to commit both the Go vendorHash file and the bun outputHash file. + // to commit the Go vendorHash file, its flake.nix mirror (identical hash, + // same buildGoModule proxyVendor set over go/ — refresh-fod-hashes.ts mirrors + // the recomputed value into it), and the bun outputHash file. Renovate only + // commits files a task's fileFilters names, so a missing flake.nix here would + // silently drop the mirror edit → a gomod bump lands with flake.nix's + // vendorHash stale and `nix flake check` red (RIG-2852 Gap 1). expect(topLevel?.fileFilters).toContain("guest-image/default.nix"); + expect(topLevel?.fileFilters).toContain("flake.nix"); expect(topLevel?.fileFilters).toContain("agent-image/entrypoint.nix"); }); @@ -662,6 +668,57 @@ describe("tools/renovate postgres + gomod go disables", () => { }); }); +describe("tools/renovate wails/v3 floor cap (RIG-2852, GTK4 migration)", () => { + // The GTK4 migration record freezes a "Never v3.1" floor: wails v3.1 removes + // the legacy GTK3 build tag, so an auto-opened v3.1 bump before the GTK4 flip + // (RIG-2819) is proven would strand the app with no native shell. A gomod + // packageRule caps github.com/wailsapp/wails/v3 below v3.1 via a REGEX + // allowedVersions (not a semver range — gomod's node-semver ranges exclude a + // prerelease at a different major.minor.patch, so `< 3.1.0-0` would wrongly + // reject the current v3.0.0 prerelease pin). Find it by behavior, not index. + const wailsRule = cfg.packageRules.find( + (r) => + r.matchManagers?.includes("gomod") && + r.matchDepNames?.includes("github.com/wailsapp/wails/v3"), + ); + + test("a gomod cap rule exists for wails/v3 with a regex allowedVersions", () => { + expect(wailsRule).toBeDefined(); + expect(wailsRule?.matchManagers).toEqual(["gomod"]); + expect(wailsRule?.matchDepNames).toEqual(["github.com/wailsapp/wails/v3"]); + const allowedVersions = wailsRule?.allowedVersions ?? ""; + // Slash-delimited regex form (matched against the raw version string), + // mirroring the postgres-stack /^18$/ rule. + expect(allowedVersions.startsWith("/")).toBe(true); + expect(allowedVersions.endsWith("/")).toBe(true); + }); + + test("the cap admits the LIVE go.mod pin + future v3.0.x, rejects v3.1.x and v4+", () => { + // Compile the shipped regex from its /.../ delimiters and replay it. The + // load-bearing assertion reads the ACTUAL wails require line from go/go.mod + // and asserts the cap admits whatever is pinned — so a future pin/regex + // pairing that would open zero PRs (the cap silently rejecting the real pin, + // the SEA-1220 freeze shape) fails HERE, tied to ground truth rather than a + // hard-coded literal. The boundary cases below then pin the reject edge so a + // fat-fingered cap (e.g. `^v?3\.`) that leaked v3.1 also fails. + const allowedVersions = wailsRule?.allowedVersions ?? ""; + const matcher = new RegExp(allowedVersions.slice(1, -1)); + + const goMod = readFileSync(join(repoRoot, "go", "go.mod"), "utf8"); + const pin = goMod.match( + /github\.com\/wailsapp\/wails\/v3\s+(?\S+)/, + )?.groups?.version; + expect(pin).toBeDefined(); // the require line must exist + expect(matcher.test(pin as string)).toBe(true); // the cap MUST admit it + + expect(matcher.test("v3.0.0-beta.7")).toBe(true); // a newer beta + expect(matcher.test("v3.0.1")).toBe(true); // a future v3.0 patch + expect(matcher.test("v3.1.0")).toBe(false); // the frozen floor + expect(matcher.test("v3.1.0-beta.0")).toBe(false); // a v3.1 prerelease + expect(matcher.test("v4.0.0")).toBe(false); // a future major + }); +}); + describe("tools/renovate postgres-stack digest manager (RIG-2774, DL-260)", () => { // DefaultPostgresImage (go/internal/stack/postgres_image.go) is a standalone Go // const the native managers can't see; a custom.regex manager surfaces it as a diff --git a/tools/renovate/refresh-fod-hashes.test.ts b/tools/renovate/refresh-fod-hashes.test.ts index d47f751b4..2e38dc4a1 100644 --- a/tools/renovate/refresh-fod-hashes.test.ts +++ b/tools/renovate/refresh-fod-hashes.test.ts @@ -78,6 +78,16 @@ const GO_NIX_FIXTURE = `let }; in guestd `; +// The flake.nix mirror carries the SAME Go vendorHash marker; the script writes +// the recomputed value here too (FodEntry.mirrorFiles). A distinct placeholder so +// a missing-mirror regression is unmistakable (it would stay at this value). +const PLACEHOLDER_GO_MIRROR = "sha256-PLACEHOLDERflakemirror00000000000000000="; +const GO_MIRROR_FIXTURE = `let + compass-app = pkgs.buildGoModule { + ${GO_ENTRY.marker}${bodyOf(PLACEHOLDER_GO_MIRROR)}"; + }; +in compass-app +`; const BUN_NIX_FIXTURE = `let nodeModules = pkgs.stdenv.mkDerivation { ${BUN_ENTRY.marker}${bodyOf(PLACEHOLDER_BUN)}"; @@ -133,6 +143,12 @@ async function buildBaselineRepo(): Promise { // Pin files at the paths the TABLE declares. await write(GO_ENTRY.file, GO_NIX_FIXTURE); await write(BUN_ENTRY.file, BUN_NIX_FIXTURE); + // Mirror pin files the Go entry declares (same vendorHash, refreshed in + // lockstep — derived from the shipped table so a rebase that edits mirrorFiles + // keeps this honest with no second edit). + for (const mirror of GO_ENTRY.mirrorFiles ?? []) { + await write(mirror, GO_MIRROR_FIXTURE); + } // Trigger manifests (content is irrelevant; only their diff-vs-base matters). for (const trigger of [...GO_ENTRY.triggers, ...BUN_ENTRY.triggers]) { await write(trigger, "baseline\n"); @@ -198,6 +214,28 @@ describe("tools/renovate/refresh-fod-hashes.ts gate (PR #579)", () => { ); }); + // RIG-2852 Gap 1: the SAME go/go.mod bump must refresh the flake.nix MIRROR to + // the identical value — not just guest-image/default.nix. Before this fix the + // mirror was never touched, so an auto-opened Go bump landed with flake.nix's + // vendorHash stale and `nix flake check` red. Assert every declared mirror got + // the go-modules SRI and none kept its distinct placeholder. + test("refreshes every flake.nix mirror to the same SRI on a go/go.mod bump", async () => { + await Bun.write(join(repo, "go/go.mod"), "bumped\n"); + + const res = await runRefresh(repo); + expect(res.exitCode).toBe(0); + + const mirrors = GO_ENTRY.mirrorFiles ?? []; + expect(mirrors.length).toBeGreaterThan(0); // the Go entry declares flake.nix + for (const mirror of mirrors) { + const text = await readFile(join(repo, mirror), "utf8"); + expect(hashOnMarker(text, GO_ENTRY.marker)).toBe( + stubSriForFragment("go-modules"), + ); + expect(text).not.toContain(bodyOf(PLACEHOLDER_GO_MIRROR)); + } + }); + // A gomod-only bump refreshes the Go vendorHash and leaves the bun outputHash // pin byte-for-byte untouched — the per-FOD self-gate granularity. test("a go/go.mod bump leaves the bun outputHash pin untouched", async () => { diff --git a/tools/renovate/refresh-fod-hashes.ts b/tools/renovate/refresh-fod-hashes.ts index 81b9f2116..fe02ad1b3 100755 --- a/tools/renovate/refresh-fod-hashes.ts +++ b/tools/renovate/refresh-fod-hashes.ts @@ -4,12 +4,19 @@ // red on a `hash mismatch in fixed-output derivation` build break (the RIG-2432 // easy-dep-bump goal, PR #579's failure class). // -// Compass pins exactly two FOD hashes outside the vendored forks/ trees, each -// content-addressing a fetched dependency set that MOVES when a manifest bumps: +// Compass pins two FOD hash VALUES outside the vendored forks/ trees, each +// content-addressing a fetched dependency set that MOVES when a manifest bumps. +// The Go vendorHash is pinned in TWO files that share it by design (below): // // guest-image/default.nix vendorHash compass-guestd's Go module set -// (buildGoModule; no vendor/ dir) — -// invalidated by a go/go.mod|go.sum bump +// flake.nix vendorHash compass-app + cmd-binaries' module set +// — the SAME proxyVendor hash over go/ +// (flake.nix:46-52 documents the equality), +// both invalidated by a go/go.mod|go.sum +// bump. The build vehicle realises ONLY +// guestd's FOD; flake.nix is refreshed as a +// MIRROR — the identical value, no second +// realise — see FodEntry.mirrorFiles. // agent-image/entrypoint.nix outputHash compass-agent's installed node_modules // tree (recursive FOD of `bun install`) — // invalidated by a bun.lock bump @@ -85,6 +92,13 @@ export type FodEntry = { // Manifests whose change invalidates this FOD (repo-root-relative). The // per-entry self-gate fires when any of these differs from the base branch. triggers: string[]; + // Extra files carrying the IDENTICAL pinned hash (same `marker`), equal to + // `file`'s by construction — e.g. a second buildGoModule with the same + // proxyVendor set over the same go/. They are NOT separately realised (the + // build vehicle content-addresses only `file`'s FOD, so a faked mirror pin + // would never surface in its output); each is rewritten to the SRI `file`'s + // realise reports. Absent for a lone pin. + mirrorFiles?: string[]; }; export const FOD_ENTRIES: FodEntry[] = [ @@ -93,6 +107,7 @@ export const FOD_ENTRIES: FodEntry[] = [ marker: 'vendorHash = "sha256-', drvFragment: "go-modules", triggers: ["go/go.mod", "go/go.sum"], + mirrorFiles: ["flake.nix"], }, { file: "agent-image/entrypoint.nix", @@ -264,6 +279,14 @@ async function main(): Promise { rewriteInlineHash(origText, entry.marker, got, entry.file), ); console.log(`renovate-fod: ${entry.file} -> ${got}`); + for (const mirror of entry.mirrorFiles ?? []) { + const mirrorText = await Bun.file(mirror).text(); + await Bun.write( + mirror, + rewriteInlineHash(mirrorText, entry.marker, got, mirror), + ); + console.log(`renovate-fod: ${mirror} (mirror) -> ${got}`); + } } console.log("renovate-fod: FOD hashes refreshed.");