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
74 changes: 74 additions & 0 deletions tools/renovate/agent-image-fod-vehicle.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# A VERIFICATION VEHICLE for the agent image's view of one shared FOD hash.
# It is NOT how the agent image is built, and nothing in the image, the publish
# lane, or the moon gates evaluates it. Its only consumer is
# tools/renovate/refresh-fod-hashes.ts, whose FOD_ENTRIES table names this file
# and the `compass-agent` attribute below as one entry's build vehicle.
#
# ── WHY IT EXISTS ──
# `agent-image/entrypoint.nix` carries a SINGLE `outputHash` literal over the
# installed `node_modules` tree, and it is imported by TWO consumers resolving
# TWO different nixpkgs revisions:
#
# guest-image/default.nix:66 with ROOT's `pkgs` (../devenv.lock)
# agent-image/devenv.nix:34 with AGENT-IMAGE's `pkgs` (../agent-image/devenv.lock)
#
# That FOD's builder takes `nativeBuildInputs = [ pkgs.bun ]`, so each consumer
# realises it with its own bun derivation, and the one committed hash is correct
# for both only while those two buns produce a byte-identical install tree.
#
# The refresher realises a vehicle to recompute the hash, and the only vehicle in
# the repo was `guest-image/default.nix` — which takes ROOT's pkgs. Nothing built
# `agent-image/devenv.nix` (it is a devenv container definition, realised by
# `devenv container build`, not by `nix build`). So the agent-image consumer's
# builder was never exercised by the refresh: if the two channel revs drifted
# onto bun versions with different install trees, the pin stayed right for
# guest-image and was silently wrong for the agent image, surfacing only when the
# heavy OCI build ran. This file closes that gap by giving that consumer a plain
# `nix build`-able expression with the SAME pkgs the image uses.
#
# ── WHY IT IS A SEPARATE FILE, NOT A CHANGE TO agent-image/ ──
# It lives under tools/renovate/ beside its sole consumer, deliberately:
#
# * agent-image/moon.yml's build task declares `inputs: ['**/*']`, so a file
# added under agent-image/ would reschedule the image build (the dominant CI
# cost, the reason ci.yml's timeout is 90m) on every edit to a file that
# cannot affect the image.
# * a nix file inside agent-image/ reads as part of the image definition. This
# one is test scaffolding for a Renovate task; putting it beside
# refresh-fod-hashes.ts is what tells the next reader that.
#
# ── HOW IT IS USED ──
# nix build -f tools/renovate/agent-image-fod-vehicle.nix compass-agent
#
# The refresher first fakes the `outputHash` in entrypoint.nix, so the build
# fails AT the fixed-output derivation with the `got: <real SRI>` line it parses,
# and never proceeds to the `bun build --compile` bundle. That fail-fast is the
# same property `guest-image/default.nix` gives the authoritative entry.
let
# The AGENT-IMAGE devenv lock's nixpkgs, resolved exactly as the repo's other
# plain-nix vehicles resolve theirs (guest-image/default.nix:51-57,
# tools/toolchain/gate-tools.nix:36-42) — read the lock, fetch that rev, import
# it. The lock path is the whole point of this file: `../../devenv.lock` (the
# root lock) is what the authoritative vehicle already uses, and reading it
# here would make this vehicle re-derive the identical value and verify
# nothing.
lock = builtins.fromJSON (builtins.readFile ../../agent-image/devenv.lock);
node = lock.nodes.nixpkgs.locked;
nixpkgsSrc = builtins.fetchTarball {
url = "https://github.com/${node.owner}/${node.repo}/archive/${node.rev}.tar.gz";
sha256 = node.narHash;
};
pkgs = import nixpkgsSrc { };
lib = pkgs.lib;
in
{
# The same import agent-image/devenv.nix performs, with the same `pkgs`.
# entrypoint.nix is `{ pkgs, lib }:` and returns the wrapper derivation, not
# the FOD — the FOD is an internal `let` binding. Realising the wrapper pulls
# the FOD in as a dependency, which is all the refresh needs: nix builds
# dependencies first, so a faked `outputHash` errors there before any of the
# bundle work starts. entrypoint.nix's own relative imports
# (../packages/compass-agent, ../package.json, …) resolve against agent-image/,
# not against this file, so importing it from here is transparent.
compass-agent = import ../../agent-image/entrypoint.nix { inherit pkgs lib; };
}
6 changes: 6 additions & 0 deletions tools/renovate/config.json5
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,12 @@
// Omit the latter and Renovate would run the refresh but silently DROP
// it, shipping a channel bump whose outputHash never moved.
//
// Step 3 also REALISES tools/renovate/agent-image-fod-vehicle.nix, to check
// the same outputHash through the bun this lock resolves. That file is only
// read, never written, so it must NOT be listed: the allowlist governs what
// Renovate commits, and adding a file the task cannot modify is dead
// surface that reads as a claim the task writes it.
//
// Self-hosted bot config MUST allow the command:
// allowedCommands: [… , "^bun tools/renovate/refresh-agent-image-nixpkgs\\.ts$"]
// see tools/renovate/bot-config.json5 (config.test.ts pins the two
Expand Down
47 changes: 28 additions & 19 deletions tools/renovate/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1621,16 +1621,21 @@ describe("tools/renovate FOD trigger coverage (every task site, derived from FOD
test("the coupled (site, entry) set has its expected shape (guard is not vacuous)", () => {
// The guard below iterates `coupled`, so a set that emptied or thinned out
// would leave it passing while checking nothing. A bare `> 0` cannot see
// the thinning: all six pairs today bind the SAME entry
// (agent-image/entrypoint.nix, whose triggers are bun.lock, devenv.lock and
// agent-image/devenv.lock), so renaming just ONE of that entry's triggers
// dissolves the coupling at some sites while the rest keep
// the count positive. Pinning the exact count catches a partial trigger
// rename, or a fileFilters edit, that dissolves any single pairing. It does
// NOT check WHICH sites are coupled — the per-site describes above pin
// that — only that the population has not shrunk or grown. A newly coupled
// site is a deliberate edit: update this number in the same change.
expect(coupled.length).toBe(6);
// the thinning: the pairs bind only two of the three entries, and the
// entrypoint.nix pin is now carried by TWO entries (the authoritative
// write and its verify sibling) sharing one trigger list, so renaming a
// single trigger dissolves many pairings while leaving the count positive.
// Pinning the exact count catches a partial trigger rename, or a
// fileFilters edit, that dissolves any single pairing. It does NOT check
// WHICH sites are coupled — the per-site describes above pin that — only
// that the population has not shrunk or grown. A newly coupled site is a
// deliberate edit: update this number in the same change.
//
// 12 = six sites naming a trigger of the two entrypoint.nix entries
// (2 entries × 6 sites), plus the guestd vendorHash entry's zero pairs —
// its triggers are go/go.mod and go/go.sum, which the gomod MANAGER writes
// and no fileFilters names.
expect(coupled.length).toBe(12);
expect(taskSites.length).toBeGreaterThan(0);
});

Expand Down Expand Up @@ -1669,21 +1674,25 @@ describe("tools/renovate FOD trigger coverage (every task site, derived from FOD

// A command that refreshes the FOD pin IN-PROCESS rather than by shelling
// FOD_COMMAND. The guard's job is to ensure a site committing a trigger
// recomputes the pin; a script that imports the refresher and drives the same
// table satisfies that as completely as the standalone command, and adding the
// command beside it would pay a SECOND faked-pin realise (a full FOD cache
// miss plus a networked bun install) to rewrite a value already correct. Each
// entry is listed with the call site that makes it true, so this stays a
// per-script statement of fact and never a blanket exemption.
// recomputes the pin; a script that imports refreshFodEntries and drives the
// same table satisfies that as completely as the standalone command, and
// adding the command beside it would pay a SECOND faked-pin realise (a full
// FOD cache miss plus a networked bun install) to rewrite a value already
// correct. Each entry is listed with the call site that makes it true, so this
// stays a per-script statement of fact and never a blanket exemption: a script
// added here without that call fails the assertion below.
const IN_PROCESS_REFRESHERS: Record<string, string> = {
// refresh-agent-image-nixpkgs.ts: relocks the agent-image channel, then
// awaits the FOD refresh over that scope's entries.
"bun tools/renovate/refresh-agent-image-nixpkgs.ts": "refreshEntry",
// awaits refreshFodEntries(agentImageFodEntries()) — the authoritative write
// and its verify sibling, in the order refreshFodEntries enforces.
"bun tools/renovate/refresh-agent-image-nixpkgs.ts": "refreshFodEntries",
};

test("every declared in-process refresher really drives the FOD table", async () => {
// Guards the exemption itself: the claim above is only sound while each
// listed script genuinely calls the refresher, so read the source and check.
// Without this, a later edit could strip the call and the site would keep
// its pass through this table alone.
for (const [command, symbol] of Object.entries(IN_PROCESS_REFRESHERS)) {
const script = command.replace(/^bun /, "");
const source = await Bun.file(join(repoRoot, script)).text();
Expand All @@ -1709,7 +1718,7 @@ describe("tools/renovate FOD trigger coverage (every task site, derived from FOD
const found: string[] = [];
// A site refreshes the pin either by shelling FOD_COMMAND — which must run
// LAST, after whatever wrote the trigger — or by running a script that
// drives the same table in-process, which orders the two itself.
// drives the table in-process, which orders the two itself.
const inProcess = commands.filter((c) => c in IN_PROCESS_REFRESHERS);
const fodIndex = commands.indexOf(FOD_COMMAND);
if (fodIndex !== -1) {
Expand Down
4 changes: 4 additions & 0 deletions tools/renovate/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ tasks:
test:
inputs:
- '*.ts'
# The FOD verification vehicle (agent-image-fod-vehicle.nix) lives here and
# is named by refresh-fod-hashes.ts's table; the suite asserts it exists, so
# deleting or renaming it must schedule this project.
- '*.nix'
- '*.json5'
- 'tsconfig.json'
- '/tsconfig.base.json'
Expand Down
63 changes: 48 additions & 15 deletions tools/renovate/refresh-agent-image-nixpkgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,16 @@ describe("agent-image scope geometry", () => {

// The single named input, not a bare `devenv update`: relocking every input
// would bloat the PR's diff past the channel advance the branch is about.
test("the relocked input is the channel input alone", () => {
expect(NIXPKGS_INPUT).toBe("nixpkgs");
// Asserted against the real devenv.yaml rather than against the constant's own
// literal — the hazard is the input being RENAMED upstream, which a
// self-referential equality check cannot see.
test("the relocked input names a real input of this scope", () => {
const yaml = Bun.YAML.parse(
readFileSync(join(repoRoot, AGENT_IMAGE_DIR, "devenv.yaml"), "utf8"),
) as { inputs?: Record<string, { url?: string }> };
const inputs = yaml.inputs ?? {};
expect(Object.keys(inputs)).toContain(NIXPKGS_INPUT);
expect(inputs[NIXPKGS_INPUT]?.url).toContain("devenv-nixpkgs");
});

// Ground truth: the lock the config's manager governs must exist at this
Expand All @@ -114,18 +122,42 @@ describe("agent-image scope geometry", () => {

// The rev read here is the one the FOD refresh hangs off: the channel
// resolves the bun entrypoint.nix's builder uses, so the FOD table must gate
// that entry on this lock. If the table and this task disagree, the entry
// point throws rather than shipping a possibly-stale outputHash — assert the
// agreement here too so the drift fails in a unit test, not on a branch.
test("the FOD entry for entrypoint.nix is gated on this lock", () => {
const entry = FOD_ENTRIES.find(
// EVERY entry over that pin on this lock. If the table and this task disagree,
// the entry point throws rather than shipping a possibly-stale outputHash —
// assert the agreement here too so the drift fails in a unit test, not on a
// branch.
test("every FOD entry for entrypoint.nix is gated on this lock", () => {
const entries = FOD_ENTRIES.filter(
(e) => e.file === "agent-image/entrypoint.nix",
);
expect(entry).toBeDefined();
expect(entry?.triggers).toContain(AGENT_IMAGE_LOCK);
// And still on bun.lock — the manifest trigger that already existed; this
// task ADDS a cause, it does not replace one.
expect(entry?.triggers).toContain("bun.lock");
expect(entries.length).toBeGreaterThan(0);
for (const entry of entries) {
expect(entry.triggers).toContain(AGENT_IMAGE_LOCK);
// And still on bun.lock — the manifest trigger that already existed; this
// task ADDS a cause, it does not replace one.
expect(entry.triggers).toContain("bun.lock");
}
});

// This lock resolves the bun the OCI image build uses, so the shared
// outputHash is only actually CHECKED for that builder if some entry realises
// a vehicle whose pkgs come from this lock. Without one, the refresh reverts
// to a one-builder rewrite whose divergence surfaces only on the image build.
// The vehicle file must exist too — a table naming a deleted file would fail
// at realise time on a branch, not here.
test("a FOD entry realises entrypoint.nix through this lock's own pkgs", () => {
const viaThisLock = FOD_ENTRIES.filter(
(e) =>
e.file === "agent-image/entrypoint.nix" &&
e.vehicleChannelLock === AGENT_IMAGE_LOCK,
);
expect(viaThisLock.length).toBe(1);
const [entry] = viaThisLock;
if (!entry) throw new Error("expected exactly one entry via this lock");
// It VERIFIES; the authoritative write stays with the root-pkgs vehicle, or
// two writers over one marker would be last-write-wins.
expect(entry.verifyOf).toBeDefined();
expect(existsSync(join(repoRoot, entry.buildFile))).toBe(true);
});
});

Expand Down Expand Up @@ -277,9 +309,10 @@ async function buildEntryRepo(): Promise<string> {
join(repo, "agent-image", "entrypoint.nix"),
`{ pkgs, lib }:\n outputHash = "${STUB_SRI}";\n`,
);
// The single realise vehicle this scope's refresh drives.
await mkdir(join(repo, "guest-image"), { recursive: true });
await Bun.write(join(repo, "guest-image", "default.nix"), "{ }\n");
for (const entry of FOD_ENTRIES) {
await mkdir(join(repo, entry.buildFile, ".."), { recursive: true });
await Bun.write(join(repo, entry.buildFile), "{ }\n");
}
await Bun.write(join(repo, "stubbin", "nix"), STUB_NIX);
await chmod(join(repo, "stubbin", "nix"), 0o755);

Expand Down
Loading
Loading