Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
6 changes: 4 additions & 2 deletions tools/renovate/bot-config.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
38 changes: 29 additions & 9 deletions tools/renovate/config.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -518,8 +525,15 @@
// 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
Expand All @@ -540,7 +554,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",
},
},
Expand Down
16 changes: 14 additions & 2 deletions tools/renovate/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) =>
Expand Down
108 changes: 108 additions & 0 deletions tools/renovate/refresh-devenv-nixpkgs.core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/);
});
});
69 changes: 69 additions & 0 deletions tools/renovate/refresh-devenv-nixpkgs.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> =>
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
Expand Down Expand Up @@ -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/<rev> pin found in flake.nix inputs.nixpkgs.url.",
);
}
return flakeNixText.replace(FLAKE_NIXPKGS_URL_RE, `$1${newRev}$3`);
}
Loading
Loading