Skip to content
Merged
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
15 changes: 12 additions & 3 deletions tools/renovate/bot-config.json5
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
// addon, so the workflow sets RENOVATE_X_IGNORE_RE2=true to take the RegExp path
// quietly.)
//
// Four entries, all load-bearing:
// Five entries, all load-bearing:
// 1. the toolchain-hash refresh, which re-prefetches the vendored-binary
// sha256 pins a tools/toolchain/versions/*.nix bump invalidates;
// 2. the catalog lockfile regeneration. Renovate's custom.regex manager exports
Expand All @@ -78,12 +78,21 @@
// both the top-level (branch mode) and catalog-rule (update mode)
// postUpgradeTasks in config.json5 — one command string, so ONE allowlist
// entry covers both sites.
// (1), (3), and (4) are `bun <script>.ts` — the Renovate hooks are TypeScript run
// via bun (AGENTS.md "Scripts: TypeScript over bash"); bun is on the runner PATH.
// 5. the go↔go-overlay lockstep, which on a go.nix toolchain bump advances
// the go-overlay input in devenv.lock to a rev that provides the new go
// release and validates it resolves through gate-tools.nix's `langs.go`
// (the same path CI uses). go-overlay's `versions` set only carries
// releases at or before its pinned rev, so without this a bump to a newer
// go than the overlay provides evals a missing attr and reds CI
// (RIG-3100).
// (1), (3), (4), and (5) are `bun <script>.ts` — the Renovate hooks are
// TypeScript run via bun (AGENTS.md "Scripts: TypeScript over bash"); bun is on
// the runner PATH.
allowedCommands: [
"^bun tools/renovate/refresh-toolchain-hashes\\.ts$",
"^bun install --lockfile-only$",
"^bun tools/renovate/refresh-devenv-nixpkgs\\.ts$",
"^bun tools/renovate/refresh-fod-hashes\\.ts$",
"^bun tools/renovate/refresh-go-overlay\\.ts$",
],
}
57 changes: 51 additions & 6 deletions tools/renovate/config.json5
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,17 @@
// regex (the internal monorepo tracked devenv.nix; compass tracks go.nix instead). The
// version is dotted in go.nix, so there is NO dots↔underscores gymnastics —
// capture it straight for the golang-version datasource, and devenv.nix picks
// it up automatically at eval time. A go bump changes no pin file, so this
// manager carries no postUpgradeTasks refresh leg (the overlay ships the
// hashes; refresh-toolchain-hashes.ts self-gates past go.nix). Auto-opens
// like every other toolchain manager (Matt's ruling: all toolchain bumps
// auto-open). The go.mod `go` directive tracks this pin minus at most one
// minor by MANUAL policy (go/go.mod:10-12), so the gomod `go`-directive
// it up automatically at eval time. A go bump rewrites go.nix and no hash
// pin (the overlay ships the hashes), so refresh-toolchain-hashes.ts
// self-gates past it — but the go-overlay input pinned in devenv.lock can
// LAG upstream go: its `versions` set only carries releases at or before
// its rev, so a bump to a newer go than the pinned overlay provides evals a
// missing attr and reds CI. The go↔go-overlay lockstep packageRule below
// (matchDepNames ["go"]) carries the refresh leg that advances the overlay
// input in the same branch and validates the new version resolves. Auto-
// opens like every other toolchain manager (Matt's ruling: all toolchain
// bumps auto-open). The go.mod `go` directive tracks this pin minus at most
// one minor by MANUAL policy (go/go.mod:10-12), so the gomod `go`-directive
// update is disabled below — a pin bump may occasionally need a manual go.mod
// follow-up.
customType: "regex",
Expand Down Expand Up @@ -564,6 +569,46 @@
executionMode: "branch",
},
},
{
// ── Go toolchain ↔ go-overlay lockstep (RIG-3100) ──
// The go customManager (above) opens a solo branch that rewrites ONLY the
// version string in tools/toolchain/versions/go.nix. The per-platform
// hashes come from the go-overlay input, whose rev is pinned in
// devenv.lock — and go-overlay's `versions` set only carries go releases
// at or before that rev. So when Renovate bumps go.nix to a release newer
// than the pinned overlay provides, both the dev shell (devenv.nix's
// `go_X_Y_Z` attr) and the parity gate (gate-tools.nix's
// `go-bin.versions.<ver>`) eval a missing attribute and CI dies
// `attribute '"<ver>"' missing` / `gate-tools.nix langs produced no store
// paths`. This task, on that branch, advances the go-overlay input to a
// rev that provides the new version and validates it resolves through the
// SAME gate-tools.nix `langs.go` path CI uses — so a go bump lands in ONE
// green PR instead of a red one needing a manual overlay follow-up.
//
// `executionMode: "branch"` runs it once after the go.nix update is
// applied; safe here because the go pin never shares a branch — the
// versions/*.nix un-group rule above nulls its groupName, so it owns its
// own branch and thus 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 task's refresh-toolchain-hashes
// leg self-gates on the vendored-binary versions/*.nix pins and no-ops on
// go.nix anyway (go's hashes come from the overlay, not a sha256 pin), so
// nothing is lost. fileFilters is an INCLUDE allowlist — Renovate commits
// ONLY files matching it — and the refresh writes exactly devenv.lock (the
// `devenv update go-overlay` re-lock); it does NOT rewrite go.nix (the go
// manager already did) nor any hash pin, so devenv.lock is the sole entry.
//
// Self-hosted bot config MUST allow the command:
// allowedCommands: [… , "^bun tools/renovate/refresh-go-overlay\\.ts$"]
// see tools/renovate/bot-config.json5 (config.test.ts pins the two together).
matchManagers: ["custom.regex"],
matchDepNames: ["go"],
postUpgradeTasks: {
commands: ["bun tools/renovate/refresh-go-overlay.ts"],
fileFilters: ["devenv.lock"],
executionMode: "branch",
},
},
{
// ── Catalog lockfile coupling ──
// The regex manager exports no `updateArtifacts`, so NOTHING regenerates
Expand Down
68 changes: 62 additions & 6 deletions tools/renovate/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,19 +188,20 @@ describe("tools/renovate postUpgradeTasks ↔ allowedCommands (RIG-2432)", () =>
// postUpgradeTasks.commands are gated by the BOT config's global
// `allowedCommands` allowlist (a repo config cannot self-authorize a command),
// which Renovate matches UNANCHORED via regEx(pattern).test(cmd). So each
// entry's `^…$` IS the security property. Compass declares four DISTINCT
// entry's `^…$` IS the security property. Compass declares five DISTINCT
// commands across the task sites (the FOD-hash refresh rides two sites — the
// top-level branch-mode task and the catalog rule's update-mode task — so it
// appears twice in the declared list but needs only one allowlist entry); every
// distinct command must be permitted, every entry must be used, and no entry may
// be an unanchored substring rule.
// be an unanchored substring rule. RIG-3100 added the fifth: the go↔go-overlay
// lockstep on the go pin's solo branch.
const commands = allDeclaredCommands();
const distinctCommands = [...new Set(commands)];
const allowed = bot.allowedCommands ?? [];

test("declares four DISTINCT postUpgrade commands and four allowlist entries", () => {
expect(distinctCommands).toHaveLength(4);
expect(allowed).toHaveLength(4);
test("declares five DISTINCT postUpgrade commands and five allowlist entries", () => {
expect(distinctCommands).toHaveLength(5);
expect(allowed).toHaveLength(5);
});

test("the fod-hash refresh is declared at BOTH task sites (top-level + catalog)", () => {
Expand Down Expand Up @@ -251,12 +252,13 @@ describe("tools/renovate postUpgradeTasks ↔ allowedCommands (RIG-2432)", () =>
).toBe(false);
});

test("permits exactly the four RIG-2432 commands", () => {
test("permits exactly the five RIG-2432/RIG-3100 commands", () => {
expect(distinctCommands.sort()).toEqual(
[
"bun install --lockfile-only",
"bun tools/renovate/refresh-devenv-nixpkgs.ts",
"bun tools/renovate/refresh-fod-hashes.ts",
"bun tools/renovate/refresh-go-overlay.ts",
"bun tools/renovate/refresh-toolchain-hashes.ts",
].sort(),
);
Expand Down Expand Up @@ -558,6 +560,60 @@ describe("tools/renovate devenv nixpkgs lockstep", () => {
});
});

describe("tools/renovate go ↔ go-overlay lockstep (RIG-3100)", () => {
// The packageRule coupling a go.nix toolchain bump to a go-overlay input
// refresh — found by its command, not index.
const goOverlayRule = cfg.packageRules.find((r) =>
r.postUpgradeTasks?.commands?.some((c) =>
/refresh-go-overlay\.ts$/.test(c),
),
);

test("the go-overlay refresh rule matches the go dep on the custom.regex manager", () => {
expect(goOverlayRule).toBeDefined();
expect(goOverlayRule?.matchManagers).toContain("custom.regex");
expect(goOverlayRule?.matchDepNames).toContain("go");
});

// Branch-mode task over exactly devenv.lock — the sole file the refresh
// writes (the `devenv update go-overlay` re-lock). It must NOT rewrite go.nix
// (the go manager already did) nor any hash pin, so listing anything else
// would be dead filter surface; listing LESS would silent-drop the re-lock,
// shipping a go bump the overlay can't resolve → the exact CI red this task
// exists to prevent. fileFilters is an INCLUDE allowlist, so this pins it.
test("the lockstep postUpgradeTask is branch-mode over devenv.lock alone", () => {
const task = goOverlayRule?.postUpgradeTasks;
expect(task?.executionMode).toBe("branch");
expect(task?.fileFilters).toEqual(["devenv.lock"]);
expect(task?.commands).toEqual([
"bun tools/renovate/refresh-go-overlay.ts",
]);
});

// Solo-branch safety: the branch-mode task slot is winner-take-all per
// branch, so this rule is safe ONLY because the go pin never shares a branch.
// The versions/*.nix un-group rule nulls its groupName, so a go bump resolves
// to its own solo branch, never the TypeScript rollup — where it would
// collide with the top-level branch-mode task. Holds for BOTH minor and major
// bumps: the un-group rule has no matchUpdateTypes (fires for every type),
// and the go-overlay refresh rule likewise has none, so a major go bump also
// solo-branches and gets the overlay refresh on its own single task slot.
test.each(["minor", "major"] as const)(
"a go pin %s bump un-groups to its own solo branch (null), not the TS rollup",
(updateType) => {
const group = resolveGroupName({
manager: "custom.regex",
fileName: "tools/toolchain/versions/go.nix",
depName: "go",
depType: "toolchain",
updateType,
});
expect(group).toBeNull();
expect(group).not.toBe("TypeScript dependencies");
},
);
});

describe("tools/renovate solo-branch grouping outcomes", () => {
// Replay Renovate's last-match-wins packageRule semantics: a toolchain pin
// bump and the devenv digest must NEVER resolve into the 'TypeScript
Expand Down
73 changes: 73 additions & 0 deletions tools/renovate/refresh-go-overlay.core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { goOverlayLockedRev, goPinVersion } from "./refresh-go-overlay.core.ts";

// Unit tests for the pure transform core of refresh-go-overlay.ts (RIG-3100):
// reading the go-overlay locked rev out of devenv.lock and the go version out
// of go.nix. No nix / network / git — those shell-outs live in the entry point
// and are exercised by the PR's own CI run. These assert the parsing that a
// wrong line would silently corrupt (validating the eval against a stale rev or
// an `undefined` version).

const repoRoot = join(import.meta.dir, "..", "..");

describe("goOverlayLockedRev", () => {
// The extraction must recover the real go-overlay rev from the checked-in
// devenv.lock — the real-manifest guard style: a devenv lock-format change
// (the node moves/renames) fails HERE, loudly, instead of silently
// validating against a stale/wrong overlay rev in production.
test("recovers the go-overlay rev from the real devenv.lock", () => {
const lock = readFileSync(join(repoRoot, "devenv.lock"), "utf8");
const rev = goOverlayLockedRev(lock);
expect(rev).toMatch(/^[a-f0-9]{40}$/);
expect(rev).toBe(JSON.parse(lock).nodes["go-overlay"].locked.rev);
});

test("throws on invalid JSON", () => {
expect(() => goOverlayLockedRev("{not json")).toThrow(/not valid JSON/);
});

test("throws when the go-overlay node is absent", () => {
const noNode = JSON.stringify({
nodes: { nixpkgs: { locked: { rev: "x" } } },
});
expect(() => goOverlayLockedRev(noNode)).toThrow(/go-overlay rev/);
});

test("throws on a non-40-hex rev (shape drift)", () => {
const shortRev = JSON.stringify({
nodes: { "go-overlay": { locked: { rev: "abc123" } } },
});
expect(() => goOverlayLockedRev(shortRev)).toThrow(/go-overlay rev/);
});
});

describe("goPinVersion", () => {
// The extraction must recover the real go version from the checked-in go.nix
// — the same literal the config.json5 go manager's matchString keys off, so a
// go-pin format change fails HERE instead of validating the eval against an
// undefined target.
test("recovers the go version from the real go.nix", () => {
const goNix = readFileSync(
join(repoRoot, "tools", "toolchain", "versions", "go.nix"),
"utf8",
);
const version = goPinVersion(goNix);
expect(version).toMatch(/^\d+\.\d+/);
});

test("reads the version out of a version-only pin literal", () => {
expect(goPinVersion('{ version = "1.27.0"; }')).toBe("1.27.0");
});

test("throws when the version literal is absent", () => {
expect(() => goPinVersion("{ }")).toThrow(/dotted go version/);
});

test("throws on a non-dotted version (shape drift)", () => {
expect(() => goPinVersion('{ version = "latest"; }')).toThrow(
/dotted go version/,
);
});
});
73 changes: 73 additions & 0 deletions tools/renovate/refresh-go-overlay.core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Pure decision/transform core for refresh-go-overlay.ts (RIG-3100).
//
// Split out from the entry point so the load-bearing string/JSON reads — the
// go-overlay input's locked rev out of devenv.lock and the go version out of
// go.nix — are unit-testable without a nix runner, a network, or a git tree.
// The entry point owns the shell-outs (re-lock, eval); this file owns the
// parsing. NO shell-outs, NO fs beyond text passed in.

/**
* The concrete go-overlay rev the dev shell resolved, read from devenv.lock's
* `go-overlay` node (`nodes.go-overlay.locked.rev`). This is the overlay rev
* gate-tools.nix fetches (`lock.nodes.go-overlay.locked`) to resolve
* `go-bin.versions.<goPin>` — so a `devenv update go-overlay` that advances it
* is what makes a newly-released go version resolve. Parsed as JSON (devenv.lock
* is JSON), throwing loudly if the node or a 40-hex rev is absent — a shape
* change must fail the task, never silently eval against a stale (too-old) rev.
*/
export function goOverlayLockedRev(devenvLockText: string): string {
let lock: unknown;
try {
lock = JSON.parse(devenvLockText);
} catch (error) {
throw new Error(
`refresh-go-overlay: devenv.lock is not valid JSON: ${String(error)}`,
);
}
// Narrow with `in`/`typeof` at each level so every access is actually
// checked (devenv.lock is external-boundary data; no schema validator is in
// the repo). A shape change surfaces as the loud throw below, never a
// silently-wrong read.
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["go-overlay"];
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-go-overlay: could not read a 40-hex go-overlay rev from devenv.lock " +
"(nodes['go-overlay'].locked.rev) — devenv lock shape may have changed.",
);
}
return rev;
}

// The go pin literal in tools/toolchain/versions/go.nix, which single-sources
// the go toolchain version version-only (`{ version = "1.26.6"; }`, hashes come
// from go-overlay). The go customManager in config.json5 rewrites exactly this
// value on a bump; the refresh task reads it back to know which version the new
// overlay rev must provide.
const GO_PIN_VERSION_RE = /version\s*=\s*"([^"]+)"/;

/**
* The go version pinned in go.nix, read from the `version = "<ver>"` literal.
* Returns the bare version string. Throws if the literal is absent or the value
* is not a dotted version (fail loud — a missing pin must not silently no-op and
* then validate the eval against `undefined`). Mirrors the raw-text read the
* config.json5 go manager's matchString does, so both key off the same literal.
*/
export function goPinVersion(goNixText: string): string {
const match = GO_PIN_VERSION_RE.exec(goNixText);
const version = match?.[1];
if (version === undefined || !/^\d+\.\d+/.test(version)) {
throw new Error(
"refresh-go-overlay: could not read a dotted go version from go.nix " +
'(`version = "<ver>"`) — go pin shape may have changed.',
);
}
return version;
}
Loading
Loading