From ea3c8389bc4c4ad6bab873332c5803bb171abdc1 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 31 Aug 2026 17:43:35 -0400 Subject: [PATCH 1/3] fix(renovate): lockstep flake.nix nixpkgs rev on devenv-nixpkgs bump (RIG-3099) --- flake.nix | 10 +- tools/renovate/bot-config.json5 | 6 +- tools/renovate/config.json5 | 19 ++- .../refresh-devenv-nixpkgs.core.test.ts | 108 ++++++++++++++++++ tools/renovate/refresh-devenv-nixpkgs.core.ts | 69 +++++++++++ tools/renovate/refresh-devenv-nixpkgs.test.ts | 38 +++++- tools/renovate/refresh-devenv-nixpkgs.ts | 37 ++++++ 7 files changed, 275 insertions(+), 12 deletions(-) diff --git a/flake.nix b/flake.nix index bb62a4cb5..be50dcd8f 100644 --- a/flake.nix +++ b/flake.nix @@ -14,9 +14,13 @@ # that does, failing CI on skew (moon task flake-gate:flake-parity). description = "Compass — binaries, native app, and microVM stack-env"; - # Pinned to the exact rev devenv.lock's nixpkgs node records - # (c946ff36bf193309589932c371bd5ae6653c912e). flake.lock will record this rev; - # the parity gate asserts flake.lock's rev == devenv.lock's rev. + # Pinned to the exact rev devenv.lock's nixpkgs node records (the URL below is + # the single source of the concrete rev — this comment names no literal, so an + # automated devenv-nixpkgs bump that rewrites the URL leaves nothing stale + # here). flake.lock records the same rev; the parity gate + # (tools/toolchain/flake-parity.ts) asserts flake.lock's rev == devenv.lock's. + # The refresh-devenv-nixpkgs.ts postUpgradeTask keeps this URL + flake.lock in + # lockstep on a channel bump. inputs.nixpkgs.url = "github:cachix/devenv-nixpkgs/c946ff36bf193309589932c371bd5ae6653c912e"; outputs = diff --git a/tools/renovate/bot-config.json5 b/tools/renovate/bot-config.json5 index 13f468653..80a259e03 100644 --- a/tools/renovate/bot-config.json5 +++ b/tools/renovate/bot-config.json5 @@ -66,8 +66,10 @@ // the branch. Drop this entry and Renovate refuses the command, silently // restoring the stale-lock failure. // 3. the devenv-nixpkgs lockstep, which re-locks devenv.lock at a new channel - // rev and rewrites the biome catalog pin to the version that rev bakes, - // keeping dev-shell baked==catalog parity. + // rev, rewrites the biome catalog pin to the version that rev bakes + // (keeping dev-shell baked==catalog parity), and re-aligns the repo-root + // flake (flake.nix inputs.nixpkgs.url + flake.lock) to the same channel + // rev so the flake-parity gate does not red on the skew. // 4. the FOD-hash refresh, which recomputes the pinned Nix fixed-output- // derivation hashes a dep bump invalidates — the Go `vendorHash` // (guest-image/default.nix) on a gomod bump, the bun `outputHash` diff --git a/tools/renovate/config.json5 b/tools/renovate/config.json5 index 78e39a4db..6a54ac9f9 100644 --- a/tools/renovate/config.json5 +++ b/tools/renovate/config.json5 @@ -63,12 +63,19 @@ // (unlike the internal monorepo, whose meta jobs moved off GHA): compass runs all CI on GitHub // Actions and keeps every workflow `uses:` SHA-pinned — see the github-actions // manager rationale below. Dropped vs the internal monorepo: `cargo`/`rust-toolchain` (no Rust), - // `woodpecker` (no Woodpecker), and `nix` — Renovate's nix manager tracks - // `flake.lock`, and compass has NO root flake (the only flake.lock files are - // under forks/*, fenced below), while devenv.lock/devenv.yaml are not flake.lock - // (the custom git-refs manager covers them) — so a nix manager would be dead - // config. No `dockerfile` manager either: the only Dockerfiles in tree are under - // forks/*, also fenced, so it too would be dead config. + // `woodpecker` (no Woodpecker), and `nix`. Compass now DOES carry a root + // flake (flake.nix/flake.lock, added for the distribution flake + gtk4/OTel + // work), but the nix manager stays off deliberately: the flake's nixpkgs is + // pinned in lockstep with devenv.lock (inputs.nixpkgs.url hard-codes the + // devenv-nixpkgs channel rev; the flake-parity gate fails CI on any skew), and + // the devenv-nixpkgs postUpgradeTask (refresh-devenv-nixpkgs.ts) re-aligns + // flake.nix + flake.lock whenever that channel bumps. An independent nix + // manager tracking flake.lock would fight that coupling — bumping nixpkgs out + // of step with devenv.lock and reliably reddening flake-parity. The forks/* + // flake.lock files stay fenced below. devenv.lock/devenv.yaml are not + // flake.lock either (the custom git-refs manager covers them). No `dockerfile` + // manager: the only Dockerfiles in tree are under forks/*, also fenced, so it + // would be dead config. enabledManagers: [ "bun", "npm", diff --git a/tools/renovate/refresh-devenv-nixpkgs.core.test.ts b/tools/renovate/refresh-devenv-nixpkgs.core.test.ts index f0fa648b3..b92cdb535 100644 --- a/tools/renovate/refresh-devenv-nixpkgs.core.test.ts +++ b/tools/renovate/refresh-devenv-nixpkgs.core.test.ts @@ -3,8 +3,10 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { BIOME_CATALOG_KEY, + channelNixpkgsRev, innerNixpkgsRev, rewriteCatalogPin, + rewriteFlakeNixpkgsUrl, } from "./refresh-devenv-nixpkgs.core.ts"; // Unit tests for the pure transform core of refresh-devenv-nixpkgs.ts @@ -130,3 +132,109 @@ describe("rewriteCatalogPin", () => { ).toThrow(/no "catalog" block/); }); }); + +describe("channelNixpkgsRev", () => { + // The channel rev (outer nixpkgs node) is what flake.nix pins and the + // flake-parity gate compares — DISTINCT from innerNixpkgsRev. A lock-shape + // change fails HERE, loudly, instead of aligning flake.nix to a wrong rev. + test("recovers the outer nixpkgs channel rev from the real devenv.lock", () => { + const lock = readFileSync(join(repoRoot, "devenv.lock"), "utf8"); + const rev = channelNixpkgsRev(lock); + expect(rev).toMatch(/^[a-f0-9]{40}$/); + expect(rev).toBe(JSON.parse(lock).nodes.nixpkgs.locked.rev); + }); + + // The counterpart to innerNixpkgsRev's mirror test: this reads the OUTER + // channel node, not the inner src node. Assert they differ and we return the + // outer — the flake pins the channel rev, so grabbing the inner would align + // flake.nix to the wrong tree and leave the parity gate red. + test("returns the outer channel rev, not the inner src rev", () => { + const lock = readFileSync(join(repoRoot, "devenv.lock"), "utf8"); + const parsed = JSON.parse(lock); + const outer = parsed.nodes.nixpkgs.locked.rev; + const inner = parsed.nodes["nixpkgs-src"].locked.rev; + expect(outer).not.toBe(inner); + expect(channelNixpkgsRev(lock)).toBe(outer); + }); + + test("throws on invalid JSON", () => { + expect(() => channelNixpkgsRev("{not json")).toThrow(/not valid JSON/); + }); + + test("throws when the outer nixpkgs node is absent", () => { + const noNode = JSON.stringify({ + nodes: { "nixpkgs-src": { locked: { rev: "x" } } }, + }); + expect(() => channelNixpkgsRev(noNode)).toThrow(/nixpkgs rev/); + }); + + test("throws on a non-40-hex rev (shape drift)", () => { + const shortRev = JSON.stringify({ + nodes: { nixpkgs: { locked: { rev: "abc123" } } }, + }); + expect(() => channelNixpkgsRev(shortRev)).toThrow(/nixpkgs rev/); + }); +}); + +describe("rewriteFlakeNixpkgsUrl", () => { + const flake = () => readFileSync(join(repoRoot, "flake.nix"), "utf8"); + const NEW_REV = "0123456789abcdef0123456789abcdef01234567"; + + // Read the channel rev the flake currently pins straight from the live + // flake.nix, so the idempotency + change assertions track whatever is pinned + // today rather than a hardcoded literal a routine devenv-nixpkgs bump would + // silently invalidate into a red gate. + const currentFlakeRev = (): string => { + const rev = /github:cachix\/devenv-nixpkgs\/([a-f0-9]{40})/.exec( + flake(), + )?.[1]; + if (rev === undefined) + throw new Error("no devenv-nixpkgs pin in flake.nix"); + return rev; + }; + + test("rewrites the flake.nix nixpkgs pin to the new rev", () => { + const out = rewriteFlakeNixpkgsUrl(flake(), NEW_REV); + expect(out).toContain(`github:cachix/devenv-nixpkgs/${NEW_REV}`); + // The OLD pin URL is gone (the bare rev still appears in the PIN + // DISCIPLINE comment prose, so assert on the URL, not the rev alone). + expect(out).not.toContain( + `github:cachix/devenv-nixpkgs/${currentFlakeRev()}`, + ); + }); + + // Only the one URL rev changes — nothing else in the flake is touched. + test("changes exactly the pinned rev, one line", () => { + const before = flake(); + const out = rewriteFlakeNixpkgsUrl(before, NEW_REV); + const changed = out + .split("\n") + .filter((line, i) => line !== before.split("\n")[i]); + expect(changed).toEqual([ + ` inputs.nixpkgs.url = "github:cachix/devenv-nixpkgs/${NEW_REV}";`, + ]); + }); + + // A channel bump landing on the same rev (or a re-run) yields byte-identical + // text, so the entry point's no-op branch — skip write + skip flake update — + // fires correctly. + test("is idempotent: rewrite to current rev yields identical text", () => { + const before = flake(); + expect(rewriteFlakeNixpkgsUrl(before, currentFlakeRev())).toBe(before); + }); + + test("throws on a non-40-hex rev (fail loud)", () => { + expect(() => rewriteFlakeNixpkgsUrl(flake(), "abc123")).toThrow( + /non-40-hex rev/, + ); + }); + + test("throws when the flake has no devenv-nixpkgs pin", () => { + expect(() => + rewriteFlakeNixpkgsUrl( + '{ inputs.nixpkgs.url = "github:NixOS/nixpkgs"; }', + NEW_REV, + ), + ).toThrow(/no github:cachix\/devenv-nixpkgs/); + }); +}); diff --git a/tools/renovate/refresh-devenv-nixpkgs.core.ts b/tools/renovate/refresh-devenv-nixpkgs.core.ts index 66be1c516..05672955e 100644 --- a/tools/renovate/refresh-devenv-nixpkgs.core.ts +++ b/tools/renovate/refresh-devenv-nixpkgs.core.ts @@ -53,6 +53,41 @@ export function innerNixpkgsRev(devenvLockText: string): string { return rev; } +/** + * The devenv-nixpkgs CHANNEL rev the dev shell resolved, read from + * devenv.lock's outer `nixpkgs` node (`nodes.nixpkgs.locked.rev`). This is the + * rev flake.nix pins in `inputs.nixpkgs.url` and the rev the flake-parity gate + * compares against flake.lock — DISTINCT from `innerNixpkgsRev`, which reads the + * transitive `nixpkgs-src` node the channel resolves to. Same fail-loud shape + * discipline: a moved lock shape throws rather than silently reading wrong. + */ +export function channelNixpkgsRev(devenvLockText: string): string { + let lock: unknown; + try { + lock = JSON.parse(devenvLockText); + } catch (error) { + throw new Error( + `refresh-devenv-nixpkgs: devenv.lock is not valid JSON: ${String(error)}`, + ); + } + const isObj = (v: unknown): v is Record => + typeof v === "object" && v !== null; + let rev: unknown; + if (isObj(lock) && "nodes" in lock && isObj(lock.nodes)) { + const node = lock.nodes.nixpkgs; + if (isObj(node) && "locked" in node && isObj(node.locked)) { + rev = node.locked.rev; + } + } + if (typeof rev !== "string" || !/^[a-f0-9]{40}$/.test(rev)) { + throw new Error( + "refresh-devenv-nixpkgs: could not read a 40-hex nixpkgs rev from devenv.lock " + + "(nodes.nixpkgs.locked.rev) — devenv lock shape may have changed.", + ); + } + return rev; +} + // The catalog object in the root package.json: `"catalog": { … }`. `[^}]*` // stops at the first `}` — the same scope the catalog customManager in // config.json5 already trusts (the block carries no nested objects; a nested @@ -100,3 +135,37 @@ export function rewriteCatalogPin( packageJsonText.slice(blockMatch.index + block.length) ); } + +// The nixpkgs input URL in the repo-root flake.nix, which hard-codes the +// devenv-nixpkgs channel rev in the URL itself: +// inputs.nixpkgs.url = "github:cachix/devenv-nixpkgs/<40-hex-rev>"; +// The rev is captured; `flake.lock` records this same rev, and the +// flake-parity gate (tools/toolchain/flake-parity.ts) fails CI when it skews +// from devenv.lock's nixpkgs rev. A devenv-nixpkgs bump moves devenv.lock but +// leaves this literal stale, so the refresh task rewrites it in lockstep. +const FLAKE_NIXPKGS_URL_RE = + /("github:cachix\/devenv-nixpkgs\/)([a-f0-9]{40})(")/; + +/** + * Rewrite the devenv-nixpkgs rev pinned in flake.nix's `inputs.nixpkgs.url` to + * `newRev`. Returns the full file text with the one rev replaced. Idempotent: a + * URL already at `newRev` yields identical text. Throws if the pinned URL is + * absent or `newRev` is not a 40-hex rev (fail loud — a missing pin must not + * silently no-op and ship a drifted flake.lock the parity gate then reds on). + */ +export function rewriteFlakeNixpkgsUrl( + flakeNixText: string, + newRev: string, +): string { + if (!/^[a-f0-9]{40}$/.test(newRev)) { + throw new Error( + `refresh-devenv-nixpkgs: rewriteFlakeNixpkgsUrl given a non-40-hex rev ${JSON.stringify(newRev)}.`, + ); + } + if (!FLAKE_NIXPKGS_URL_RE.test(flakeNixText)) { + throw new Error( + "refresh-devenv-nixpkgs: no github:cachix/devenv-nixpkgs/ pin found in flake.nix inputs.nixpkgs.url.", + ); + } + return flakeNixText.replace(FLAKE_NIXPKGS_URL_RE, `$1${newRev}$3`); +} diff --git a/tools/renovate/refresh-devenv-nixpkgs.test.ts b/tools/renovate/refresh-devenv-nixpkgs.test.ts index 355f5ec14..be378b25e 100644 --- a/tools/renovate/refresh-devenv-nixpkgs.test.ts +++ b/tools/renovate/refresh-devenv-nixpkgs.test.ts @@ -113,6 +113,20 @@ function packageJson(biome: string): string { )}\n`; } +// Minimal root flake.nix whose inputs.nixpkgs.url hard-codes the devenv-nixpkgs +// channel (OUTER) rev — the literal step 6 must rewrite in lockstep with a +// channel bump. A second, prose mention of the rev guards that the rewrite +// touches ONLY the URL, not documentation that legitimately names the rev. +function flakeNix(outerRev: string): string { + return `{ + # Pinned to devenv.lock's channel rev (${outerRev}); the parity gate asserts + # flake.lock's rev == devenv.lock's. + inputs.nixpkgs.url = "github:cachix/devenv-nixpkgs/${outerRev}"; + outputs = { self, nixpkgs }: { }; +} +`; +} + // Stub `devenv`: on `devenv update nixpkgs`, rewrite devenv.lock to the BUMPED // inner rev — simulating the real re-lock resolving the channel's nixpkgs-src. // Any other invocation is a no-op success. Offline. @@ -132,7 +146,16 @@ exit 0 // Emits GARBAGE for an unknown rev so the fail-loud path is reachable. const STUB_NIX = `#!/usr/bin/env bash set -euo pipefail -# last arg is the flake ref: github:NixOS/nixpkgs/#legacyPackages...version +# Step 6 re-locks the flake: \`nix flake update nixpkgs …\`. Record it ran (so the +# harness can assert step 6 fired) and rewrite a marker flake.lock; no network. +if [ "\${1:-}" = "flake" ] && [ "\${2:-}" = "update" ]; then + touch .nix-flake-update-ran + exit 0 +fi +# Otherwise a \`nix eval --raw … NixOS/nixpkgs/#….version\` → a version +# string keyed off BOTH the rev and the attr, so the assertion proves the +# script evaluated the bumped INNER rev (not the outer/base) for biome. +# Emits GARBAGE for an unknown rev so the fail-loud path is reachable. ref="\${@: -1}" rev="\${ref#github:NixOS/nixpkgs/}"; rev="\${rev%%#*}" attrpath="\${ref#*#}"; attr="\${attrpath%.version}"; attr="\${attr##*.}" @@ -177,6 +200,7 @@ async function buildRepo(): Promise { devenvLock(OUTER_REV_BASE, INNER_REV_BASE), ); await Bun.write(join(repo, "package.json"), packageJson("2.4.16")); + await Bun.write(join(repo, "flake.nix"), flakeNix(OUTER_REV_BASE)); for (const [name, body] of [ ["devenv", STUB_DEVENV], @@ -259,6 +283,18 @@ describe("tools/renovate/refresh-devenv-nixpkgs.ts lockstep (RIG-2432)", () => { expect(pkg).toContain('"@biomejs/biome": "catalog:"'); // Step 5 fired (bun install --lockfile-only). expect(await Bun.file(join(repo, ".bun-install-ran")).exists()).toBe(true); + // Step 6: flake.nix rewritten to the BUMPED OUTER (channel) rev, and the + // flake re-lock ran. Renovate moved devenv.lock's channel rev; step 6 + // keeps flake.nix's inputs.nixpkgs.url + flake.lock in lockstep so the + // flake-parity gate does not red on the skew. + const flake = await readFile(join(repo, "flake.nix"), "utf8"); + expect(flake).toContain(`github:cachix/devenv-nixpkgs/${OUTER_REV_BUMP}`); + expect(flake).not.toContain( + `github:cachix/devenv-nixpkgs/${OUTER_REV_BASE}`, + ); + expect(await Bun.file(join(repo, ".nix-flake-update-ran")).exists()).toBe( + true, + ); }); // No-op-rewrite branch: a channel bump that does NOT move biome (the stub diff --git a/tools/renovate/refresh-devenv-nixpkgs.ts b/tools/renovate/refresh-devenv-nixpkgs.ts index ca79518b0..1610cc9db 100644 --- a/tools/renovate/refresh-devenv-nixpkgs.ts +++ b/tools/renovate/refresh-devenv-nixpkgs.ts @@ -28,6 +28,10 @@ // same channel, but it carries no catalog pin, so only biome is rewritten. // 5. `bun install --lockfile-only` — re-resolve bun.lock so the fail-closed // `bun install --frozen-lockfile` root-check passes. +// 6. Lockstep the repo-root flake: rewrite flake.nix's inputs.nixpkgs.url to +// the new devenv.lock channel rev and `nix flake update nixpkgs` to +// re-lock flake.lock, so the flake-parity gate (flake-gate:flake-parity) +// does not red on the skew a channel bump otherwise leaves behind. // // Design: docs/designs/repo/compass-renovate-migration.md // @@ -46,14 +50,20 @@ import { readFileSync } from "node:fs"; import { $ } from "bun"; import { BIOME_CATALOG_KEY, + channelNixpkgsRev, innerNixpkgsRev, rewriteCatalogPin, + rewriteFlakeNixpkgsUrl, } from "./refresh-devenv-nixpkgs.core.ts"; // The devenv channel lock + the root manifest whose catalog pin mirrors the // baked biome. Repo-root-relative (the runner cwd = repo root). const DEVENV_LOCK = "devenv.lock"; const PACKAGE_JSON = "package.json"; +// The repo-root distribution flake, whose inputs.nixpkgs.url hard-codes the +// devenv-nixpkgs channel rev. flake.lock records the same rev; the flake-parity +// gate (tools/toolchain/flake-parity.ts) reds CI when it skews from devenv.lock. +const FLAKE_NIX = "flake.nix"; // The nixpkgs system the dev shell bakes for; eval the same attr set the baked // derivations come from. @@ -157,6 +167,33 @@ async function main(): Promise { await $`bun install --lockfile-only`; } + // ── Step 6: lockstep the repo-root flake to the new channel rev. ── + // The re-lock in step 2 moved devenv.lock's outer nixpkgs (channel) rev, but + // flake.nix hard-codes that rev in inputs.nixpkgs.url and flake.lock records + // it independently — so without this the flake-parity gate + // (flake-gate:flake-parity) reds on the skew. Rewrite the URL rev to the new + // channel rev, then re-lock flake.lock to match. `nix flake update nixpkgs` + // re-locks only the nixpkgs input (no build). Idempotent: a channel bump that + // somehow left the rev unchanged rewrites nothing and the flake update is a + // no-op. Fail loud — a half-aligned flake ships a red parity gate. + const channelRev = channelNixpkgsRev(readFileSync(DEVENV_LOCK, "utf8")); + const flakeBefore = readFileSync(FLAKE_NIX, "utf8"); + const flakeAfter = rewriteFlakeNixpkgsUrl(flakeBefore, channelRev); + if (flakeAfter !== flakeBefore) { + await Bun.write(FLAKE_NIX, flakeAfter); + console.log( + `refresh-devenv-nixpkgs: rewrote flake.nix nixpkgs pin to channel rev ${channelRev}.`, + ); + console.log( + "refresh-devenv-nixpkgs: re-locking flake.lock (nix flake update nixpkgs) ...", + ); + await $`nix flake update nixpkgs --extra-experimental-features ${"nix-command flakes"}`; + } else { + console.log( + "refresh-devenv-nixpkgs: flake.nix nixpkgs pin already at the channel rev; no rewrite.", + ); + } + console.log("refresh-devenv-nixpkgs: done."); return 0; } From ce7792aa46f0814100c02d0214bc0a1108768e4d Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 31 Aug 2026 18:30:46 -0400 Subject: [PATCH 2/3] fix(renovate): commit flake.nix + flake.lock via fileFilters; guard + robustness (RIG-3099 review) --- tools/renovate/config.json5 | 20 +++++-- tools/renovate/config.test.ts | 16 +++++- tools/renovate/refresh-devenv-nixpkgs.test.ts | 54 +++++++++++++++++-- tools/renovate/refresh-devenv-nixpkgs.ts | 28 ++++++++-- 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/tools/renovate/config.json5 b/tools/renovate/config.json5 index 6a54ac9f9..1dca874b9 100644 --- a/tools/renovate/config.json5 +++ b/tools/renovate/config.json5 @@ -522,11 +522,17 @@ // dev-shell baked==catalog parity holds in one PR. `executionMode: "branch"` // runs it once after the digest update is applied; safe here because this dep // never shares a branch (its own groupName above), so it owns the single - // branch-mode task slot Renovate builds per branch. Rule-level // postUpgradeTasks REPLACE the top-level one for matching branches; the // evicted top-level toolchain task self-gates on the versions/*.nix pins and - // would be a no-op here anyway. fileFilters cover exactly the files the script - // writes (compass has no committed inner-rev guard file, so no such entry). + // would be a no-op here anyway. fileFilters is an INCLUDE allowlist — + // Renovate commits ONLY files matching it — so it must name every file the + // script writes: devenv.lock + package.json (biome catalog pin) + bun.lock + // (steps 2/4/5), and flake.nix + flake.lock (step 6's flake lockstep). Omit + // the flake pair and Renovate would run the re-lock but silently DROP it + // from the commit, shipping a channel bump with the flake still skewed → + // flake-parity red on every bump (the same silent-drop mode the FOD guard + // in config.test.ts documents). compass has no committed inner-rev guard + // file, so no such entry. // // FOD-REFRESH IS INTENTIONALLY ABSENT here, though this branch rewrites // bun.lock + a workspace manifest (the biome catalog pin) — the two inputs @@ -547,7 +553,13 @@ // see tools/renovate/bot-config.json5 (config.test.ts pins the two together). postUpgradeTasks: { commands: ["bun tools/renovate/refresh-devenv-nixpkgs.ts"], - fileFilters: ["devenv.lock", "package.json", "bun.lock"], + fileFilters: [ + "devenv.lock", + "package.json", + "bun.lock", + "flake.nix", + "flake.lock", + ], executionMode: "branch", }, }, diff --git a/tools/renovate/config.test.ts b/tools/renovate/config.test.ts index c1af96d9d..6698a48f2 100644 --- a/tools/renovate/config.test.ts +++ b/tools/renovate/config.test.ts @@ -496,8 +496,10 @@ describe("tools/renovate devenv nixpkgs lockstep", () => { expect(devenvRule?.minimumReleaseAge).toBeNull(); }); - // Branch-mode lockstep task over exactly the three files the script writes - // (compass has NO committed inner-rev guard file, unlike the internal monorepo's fourth entry). + // Branch-mode lockstep task over exactly the five files the script writes: + // devenv.lock + package.json (biome catalog pin) + bun.lock (steps 2/4/5), + // and flake.nix + flake.lock (step 6's flake-parity lockstep). compass has NO + // committed inner-rev guard file, unlike the internal monorepo's guard entry. // // The `every(... refresh-devenv-nixpkgs ...)` assertion also PINS the verified // FOD-refresh exemption: this branch rewrites bun.lock + the biome catalog pin, @@ -514,7 +516,17 @@ describe("tools/renovate devenv nixpkgs lockstep", () => { "devenv.lock", "package.json", "bun.lock", + "flake.nix", + "flake.lock", ]); + // Silent-drop guard (mirrors the top-level rule's flake.nix guard): step 6 + // writes flake.nix + flake.lock, and fileFilters is an INCLUDE allowlist — + // Renovate commits ONLY listed files. Drop either from the filter and a + // channel bump ships with the flake skewed from devenv.lock → flake-parity + // reds on every bump while the script's own tests stay green. These two + // asserts turn that silent drop into a red test. + expect(task?.fileFilters).toContain("flake.nix"); + expect(task?.fileFilters).toContain("flake.lock"); expect(task?.commands?.length).toBeGreaterThan(0); expect( task?.commands?.every((c) => diff --git a/tools/renovate/refresh-devenv-nixpkgs.test.ts b/tools/renovate/refresh-devenv-nixpkgs.test.ts index be378b25e..bf346f535 100644 --- a/tools/renovate/refresh-devenv-nixpkgs.test.ts +++ b/tools/renovate/refresh-devenv-nixpkgs.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "bun"; +import { nixpkgsLockedRev } from "../toolchain/flake-parity-core.ts"; // Orchestration harness for tools/renovate/refresh-devenv-nixpkgs.ts // (RIG-2432). @@ -23,6 +24,15 @@ const SCRIPT_REL = "tools/renovate/refresh-devenv-nixpkgs.ts"; const CORE_REL = "tools/renovate/refresh-devenv-nixpkgs.core.ts"; const REAL_SCRIPT = join(import.meta.dir, "refresh-devenv-nixpkgs.ts"); const REAL_CORE = join(import.meta.dir, "refresh-devenv-nixpkgs.core.ts"); +// Step 6 imports nixpkgsLockedRev from ../toolchain/flake-parity-core.ts, so the +// throwaway repo must carry it at the same repo-root-relative path. +const PARITY_CORE_REL = "tools/toolchain/flake-parity-core.ts"; +const REAL_PARITY_CORE = join( + import.meta.dir, + "..", + "toolchain", + "flake-parity-core.ts", +); // Hermetic git: identity from env only, no user/global/system config leakage. const HERMETIC_ENV = { @@ -127,6 +137,25 @@ function flakeNix(outerRev: string): string { `; } +// Minimal flake.lock recording the nixpkgs channel rev in the `nodes.nixpkgs. +// locked.rev` path the parity gate compares (flake-parity-core.nixpkgsLockedRev). +// Seeded at the BASE rev so a bump makes step 6's `flakeLockRev !== channelRev` +// gate fire; the stub `nix flake update` rewrites it to the bumped rev. +function flakeLock(outerRev: string): string { + return `${JSON.stringify( + { + nodes: { + nixpkgs: { locked: { rev: outerRev, type: "github" } }, + root: {}, + }, + root: "root", + version: 7, + }, + null, + 2, + )}\n`; +} + // Stub `devenv`: on `devenv update nixpkgs`, rewrite devenv.lock to the BUMPED // inner rev — simulating the real re-lock resolving the channel's nixpkgs-src. // Any other invocation is a no-op success. Offline. @@ -146,10 +175,17 @@ exit 0 // Emits GARBAGE for an unknown rev so the fail-loud path is reachable. const STUB_NIX = `#!/usr/bin/env bash set -euo pipefail -# Step 6 re-locks the flake: \`nix flake update nixpkgs …\`. Record it ran (so the -# harness can assert step 6 fired) and rewrite a marker flake.lock; no network. +# Step 6 re-locks the flake: \`nix flake update nixpkgs …\`. Simulate the real +# re-lock offline: read the rev flake.nix now pins (step 6 rewrote it first) and +# write a flake.lock whose nodes.nixpkgs.locked.rev matches — the exact field the +# parity gate compares — then record it ran so the harness can assert step 6 +# fired. No network. if [ "\${1:-}" = "flake" ] && [ "\${2:-}" = "update" ]; then touch .nix-flake-update-ran + rev=$(grep -oE 'devenv-nixpkgs/[a-f0-9]{40}' flake.nix | head -1 | cut -d/ -f2) + cat > flake.lock <#….version\` → a version @@ -191,9 +227,15 @@ async function buildRepo(): Promise { await mkdir(join(repo, "tools", "renovate"), { recursive: true }); await mkdir(join(repo, "stubbin"), { recursive: true }); - // Ship the REAL script + its core. + // Ship the REAL script + its core + the flake-parity-core module step 6 + // imports (nixpkgsLockedRev), so the SHIPPED file runs unmodified. await Bun.write(join(repo, SCRIPT_REL), await readFile(REAL_SCRIPT, "utf8")); await Bun.write(join(repo, CORE_REL), await readFile(REAL_CORE, "utf8")); + await mkdir(join(repo, "tools", "toolchain"), { recursive: true }); + await Bun.write( + join(repo, PARITY_CORE_REL), + await readFile(REAL_PARITY_CORE, "utf8"), + ); await Bun.write( join(repo, "devenv.lock"), @@ -201,6 +243,7 @@ async function buildRepo(): Promise { ); await Bun.write(join(repo, "package.json"), packageJson("2.4.16")); await Bun.write(join(repo, "flake.nix"), flakeNix(OUTER_REV_BASE)); + await Bun.write(join(repo, "flake.lock"), flakeLock(OUTER_REV_BASE)); for (const [name, body] of [ ["devenv", STUB_DEVENV], @@ -295,6 +338,11 @@ describe("tools/renovate/refresh-devenv-nixpkgs.ts lockstep (RIG-2432)", () => { expect(await Bun.file(join(repo, ".nix-flake-update-ran")).exists()).toBe( true, ); + // flake.lock ends pinned at the bumped channel rev — the exact field the + // parity gate compares. Closes the loop on the side the gate reads (the + // script-only tests otherwise never check flake.lock's end state). + const flakeLockText = await readFile(join(repo, "flake.lock"), "utf8"); + expect(nixpkgsLockedRev(flakeLockText)).toBe(OUTER_REV_BUMP); }); // No-op-rewrite branch: a channel bump that does NOT move biome (the stub diff --git a/tools/renovate/refresh-devenv-nixpkgs.ts b/tools/renovate/refresh-devenv-nixpkgs.ts index 1610cc9db..b054f861c 100644 --- a/tools/renovate/refresh-devenv-nixpkgs.ts +++ b/tools/renovate/refresh-devenv-nixpkgs.ts @@ -48,6 +48,10 @@ import { readFileSync } from "node:fs"; import { $ } from "bun"; +// The flake-parity gate's own rev extractor — reused here so step 6 keys its +// re-lock off flake.lock's ACTUAL recorded rev (the exact value the gate +// compares), not merely off flake.nix's text having changed. +import { nixpkgsLockedRev } from "../toolchain/flake-parity-core.ts"; import { BIOME_CATALOG_KEY, channelNixpkgsRev, @@ -64,6 +68,7 @@ const PACKAGE_JSON = "package.json"; // devenv-nixpkgs channel rev. flake.lock records the same rev; the flake-parity // gate (tools/toolchain/flake-parity.ts) reds CI when it skews from devenv.lock. const FLAKE_NIX = "flake.nix"; +const FLAKE_LOCK = "flake.lock"; // The nixpkgs system the dev shell bakes for; eval the same attr set the baked // derivations come from. @@ -173,24 +178,37 @@ async function main(): Promise { // it independently — so without this the flake-parity gate // (flake-gate:flake-parity) reds on the skew. Rewrite the URL rev to the new // channel rev, then re-lock flake.lock to match. `nix flake update nixpkgs` - // re-locks only the nixpkgs input (no build). Idempotent: a channel bump that - // somehow left the rev unchanged rewrites nothing and the flake update is a - // no-op. Fail loud — a half-aligned flake ships a red parity gate. + // re-locks only the nixpkgs input (no build). + // + // The re-lock is gated on flake.lock's ACTUAL recorded rev, not merely on + // flake.nix's text changing: a half-aligned state (flake.nix already at the + // new rev but flake.lock stale — a prior run that rewrote+committed flake.nix + // then failed the update, re-driven on a rebase) must still re-lock. So we + // run the update whenever EITHER flake.nix was just rewritten OR flake.lock's + // rev != the channel rev. Idempotent: a fully-aligned flake rewrites nothing + // and skips the update. Fail loud — a half-aligned flake ships a red gate. const channelRev = channelNixpkgsRev(readFileSync(DEVENV_LOCK, "utf8")); const flakeBefore = readFileSync(FLAKE_NIX, "utf8"); const flakeAfter = rewriteFlakeNixpkgsUrl(flakeBefore, channelRev); - if (flakeAfter !== flakeBefore) { + const flakeNixRewritten = flakeAfter !== flakeBefore; + if (flakeNixRewritten) { await Bun.write(FLAKE_NIX, flakeAfter); console.log( `refresh-devenv-nixpkgs: rewrote flake.nix nixpkgs pin to channel rev ${channelRev}.`, ); + } + // flake.lock's recorded rev — null if the lock is absent/misshapen, which we + // treat as "needs a re-lock" (the update will (re)create it) rather than a + // silent skip. + const flakeLockRev = nixpkgsLockedRev(readFileSync(FLAKE_LOCK, "utf8")); + if (flakeNixRewritten || flakeLockRev !== channelRev) { console.log( "refresh-devenv-nixpkgs: re-locking flake.lock (nix flake update nixpkgs) ...", ); await $`nix flake update nixpkgs --extra-experimental-features ${"nix-command flakes"}`; } else { console.log( - "refresh-devenv-nixpkgs: flake.nix nixpkgs pin already at the channel rev; no rewrite.", + "refresh-devenv-nixpkgs: flake.nix + flake.lock already at the channel rev; no flake re-lock.", ); } From 62e6b0c73619676c857baf6818fa477c3d1fb01d Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 31 Aug 2026 18:58:20 -0400 Subject: [PATCH 3/3] fix(renovate): restore comment clause + assert step 6 fires on biome-unchanged bump (RIG-3099 review R2) Round-2 review lows: - config.json5: restore the comment clause an earlier edit dropped (the single branch-mode task slot Renovate builds per branch / Rule-level). - refresh-devenv-nixpkgs.test.ts: the 'biome did not move' test now asserts step 6 STILL fired (flake.nix URL rev + flake.lock locked rev both track the bumped channel), pinning that the flake lockstep is decoupled from the biome-pin rewrite -- its whole reason to be a separate step. --- tools/renovate/config.json5 | 1 + tools/renovate/refresh-devenv-nixpkgs.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/tools/renovate/config.json5 b/tools/renovate/config.json5 index 1dca874b9..8ff3c5035 100644 --- a/tools/renovate/config.json5 +++ b/tools/renovate/config.json5 @@ -522,6 +522,7 @@ // dev-shell baked==catalog parity holds in one PR. `executionMode: "branch"` // runs it once after the digest update is applied; safe here because this dep // never shares a branch (its own groupName above), so it owns the single + // branch-mode task slot Renovate builds per branch. Rule-level // postUpgradeTasks REPLACE the top-level one for matching branches; the // evicted top-level toolchain task self-gates on the versions/*.nix pins and // would be a no-op here anyway. fileFilters is an INCLUDE allowlist — diff --git a/tools/renovate/refresh-devenv-nixpkgs.test.ts b/tools/renovate/refresh-devenv-nixpkgs.test.ts index bf346f535..e5952a1d5 100644 --- a/tools/renovate/refresh-devenv-nixpkgs.test.ts +++ b/tools/renovate/refresh-devenv-nixpkgs.test.ts @@ -367,6 +367,14 @@ describe("tools/renovate/refresh-devenv-nixpkgs.ts lockstep (RIG-2432)", () => { expect(res.stdout.toString()).toContain("already match"); // Lockfile re-resolve skipped (no pin change). expect(await Bun.file(join(repo, ".bun-install-ran")).exists()).toBe(false); + // ...but step 6 STILL fired: the flake lockstep is decoupled from the + // biome-pin rewrite (its whole reason to be a separate step). The channel + // moved, so flake.nix's URL rev + flake.lock's locked rev must both track + // it even though package.json/bun.lock stayed put. + const flake = await readFile(join(repo, "flake.nix"), "utf8"); + expect(flake).toContain(`github:cachix/devenv-nixpkgs/${OUTER_REV_BUMP}`); + const flakeLockText = await readFile(join(repo, "flake.lock"), "utf8"); + expect(nixpkgsLockedRev(flakeLockText)).toBe(OUTER_REV_BUMP); }); // Fail-loud: if the version eval yields a non-version string (a broken rev,