From 45cdfd23157180096d998cd4ca050b64e636a8a8 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 25 Aug 2026 23:48:26 -0400 Subject: [PATCH] feat(flake): repo-root flake.nix packaging + nixpkgs-pin parity gate (RIG-2757, T6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a repo-root flake.nix packaging the four backend binaries (compass, compass-server, compass-runner, compass-stack — one shared version stamp, Global Constraint 4), the Linux gtk3 cgo native app (compass-app, built against the shared SEA-1172 gtk-closure.nix so the dev shell, e2e helper, and flake cannot drift), and the microVM stack-env trio (cloud-hypervisor + virtiofsd + passt). nixpkgs is pinned to devenv.lock's rev. Because the flake carries its OWN flake.lock — a second independent nixpkgs pin — a devenv bump could silently skew the 'one closure' claim. tools/toolchain/ flake-parity.ts (thin shell over a pure, unit-tested core, mirroring parity.ts) enforces flake.lock's nixpkgs rev == devenv.lock's, wired as the flake-gate moon project (nix-source, ci-group.nix) running nix flake check + the parity gate, affected-gated on the flake build closure. aarch64-darwin is deferred with a TODO (the darwin app links system WebKit, not the gtk closure). Implements DL-259 install surface (flake as the self-host stack's install path). Co-authored-by: Matt Wilkinson --- .moon/workspace.yml | 9 ++ flake.lock | 47 ++++++++ flake.nix | 130 ++++++++++++++++++++++ tools/flake-gate/moon.yml | 94 ++++++++++++++++ tools/toolchain/flake-parity-core.test.ts | 103 +++++++++++++++++ tools/toolchain/flake-parity-core.ts | 109 ++++++++++++++++++ tools/toolchain/flake-parity.ts | 36 ++++++ 7 files changed, 528 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 tools/flake-gate/moon.yml create mode 100644 tools/toolchain/flake-parity-core.test.ts create mode 100644 tools/toolchain/flake-parity-core.ts create mode 100755 tools/toolchain/flake-parity.ts diff --git a/.moon/workspace.yml b/.moon/workspace.yml index be6ef492..8060e696 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -51,6 +51,15 @@ projects: # bundle build rides the gate per-PR when a packaging input changes, and the # unconditional main + nightly full sweep (see app-bundle/moon.yml). compass-app-bundle: 'app-bundle' + # The repo-root flake gate (compass-distribution §T6): `nix flake check` + # (the flake evaluates + every package builds from a bare checkout) plus the + # nixpkgs-pin parity check (flake.lock's nixpkgs rev == devenv.lock's — the + # flake's SECOND independent lock cannot silently skew the "one closure" + # claim). A nix-source project like agent-image/guest-image/app-bundle; + # registered here so both checks ride the affected-gated pre-merge gate (its + # closure includes flake.nix/flake.lock, go/, and devenv.lock — the drift + # event). See tools/flake-gate/moon.yml. + flake-gate: 'tools/flake-gate' # The toolchain version-parity gate: asserts CI's PATH holds the dev shell's # toolchain, and carries the unit tests for its own comparison logic. toolchain-parity: 'tools/toolchain' diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..6e8739b4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,47 @@ +{ + "nodes": { + "nixpkgs": { + "inputs": { + "nixpkgs-src": "nixpkgs-src" + }, + "locked": { + "lastModified": 1785104946, + "narHash": "sha256-VLSslwCjlsICjyaUfrS7lGMypmO03fzmDAqhD85Ae84=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "c946ff36bf193309589932c371bd5ae6653c912e", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "c946ff36bf193309589932c371bd5ae6653c912e", + "type": "github" + } + }, + "nixpkgs-src": { + "flake": false, + "locked": { + "lastModified": 1784783405, + "narHash": "sha256-4IHyyLgLBdKefkljdKod4IMn023pQiDXAWJA187cmdY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7525d999cd850b9a488817abc89c75dc733acf17", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..d979ab7e --- /dev/null +++ b/flake.nix @@ -0,0 +1,130 @@ +{ + # Compass distribution flake (docs/designs/platform/compass-distribution/design.md + # §T6). Packages the four backend binaries + the native gtk3 app + the + # microVM stack-env from a bare checkout, so + # `nix profile install github:RigelBuild/compass#` and + # `nix run .#compass-stack -- status` work with nothing but nix on PATH. + # + # PIN DISCIPLINE (the gtk-e2e-env.nix:9-13 single-pin rule): nixpkgs is pinned + # to the SAME revision devenv.lock resolves (cachix/devenv-nixpkgs, the rolling + # devenv channel), so the flake-built binaries link byte-for-byte the libraries + # a dev box and the app-bundle build do. A flake carries its OWN flake.lock, so + # this is a SECOND independent nixpkgs lock — nothing enforces it stays equal to + # devenv.lock by construction. tools/toolchain/flake-parity.ts is the named gate + # 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. + inputs.nixpkgs.url = "github:cachix/devenv-nixpkgs/c946ff36bf193309589932c371bd5ae6653c912e"; + + outputs = + { self, nixpkgs }: + let + # A manual forAllSystems (no flake-utils dependency — the record's preferred + # simplest shape). x86_64-linux is the load-bearing system: it builds every + # package including the gtk3 cgo app. aarch64-darwin is a follow-up (see the + # TODO in the per-system set below) — not blocked on here. + systems = [ "x86_64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { inherit system; })); + + # ONE version string stamped into all four backend binaries + the app + # (Global Constraint 4: the stack binaries carry ONE stamp). Short form of + # the flake rev; dirtyShortRev on an uncommitted working copy; "dev" when + # neither is available (a bare tree with no VCS metadata). + version = self.shortRev or self.dirtyShortRev or "dev"; + + # The backend module rooted at go/ (github.com/RigelBuild/compass/go). + # Renamed off `go` (buildGoModule unpacks src into $GOPATH=/build/go, and a + # root literally named `go` collides — see guest-image/default.nix:78-81). + goSrc = builtins.path { + path = ./go; + name = "compass-go-src"; + }; + + # proxyVendor: the backend pulls wails/secretspec, whose //go:embed patterns + # reference darwin/windows-only asset files a vendor-tree build fails on; + # proxyVendor populates the module cache so only compiled packages are + # touched (guest-image/default.nix:82-87). vendorHash pins the fetched set — + # the whole module graph, so it matches guestd's proxyVendor hash. Recompute + # with lib.fakeHash on a go.mod/go.sum move. + vendorHash = "sha256-Kc9UqyX1ZB1Vhbdx2lCTsbTrX/Vt/pXrJDYefnnE+Zw="; + in + { + packages = forAllSystems ( + pkgs: + let + # One CGO_ENABLED=0 backend binary, version-stamped. Each of the four + # shares this builder so they carry the identical stamp. + goBin = + name: + pkgs.buildGoModule { + pname = name; + inherit version; + src = goSrc; + subPackages = [ "cmd/${name}" ]; + proxyVendor = true; + inherit vendorHash; + env.CGO_ENABLED = 0; + ldflags = [ "-X main.version=${version}" ]; + # Package-level logic is gated under compass-go:ci; re-running the + # suite in the nix build would only re-pay it. + doCheck = false; + }; + in + { + compass = goBin "compass"; + compass-server = goBin "compass-server"; + compass-runner = goBin "compass-runner"; + compass-stack = goBin "compass-stack"; + + # The Linux gtk3 cgo native shell (Wails v3). Links the SEA-1172 + # WebKitGTK closure through cgo — the same gtk-closure.nix the dev shell + # and the e2e helper realize, applied against this flake's pinned pkgs so + # the three cannot drift (gtk-e2e-env.nix:38). tags=[gtk3] selects the + # gtk3 build (main.go's //go:build unix && gtk3). + # + # TODO(aarch64-darwin follow-up): the darwin app links system WebKit via + # frameworks, NOT this gtk closure — no pkg-config/gtk buildInputs, a + # different tag set. Out of scope for this slice (systems is x86_64-linux + # only); add a darwin branch when the systems list grows. + compass-app = pkgs.buildGoModule { + pname = "compass-app"; + inherit version; + src = goSrc; + subPackages = [ "cmd/compass-app" ]; + proxyVendor = true; + inherit vendorHash; + env.CGO_ENABLED = 1; + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = pkgs.lib.closePropagation (import ./tools/toolchain/gtk-closure.nix pkgs); + tags = [ "gtk3" ]; + ldflags = [ "-X main.version=${version}" ]; + doCheck = false; + }; + + # The microVM stack runtime trio (cloud-hypervisor + virtiofsd + passt) + # at the pinned rev, joined so `nix profile install .#compass-stack-env` + # puts all three on PATH for the stack's LookPath spawns. + compass-stack-env = pkgs.symlinkJoin { + name = "compass-stack-env-${version}"; + paths = [ + pkgs.cloud-hypervisor + pkgs.virtiofsd + pkgs.passt + ]; + }; + } + ); + + # `nix flake check` builds only the flake's `checks.*` outputs — it merely + # EVALUATES `packages.*` to a .drv without realizing them, so a build-time + # break (a go compile error, a vendorHash drift) would pass flake-check + # green. Aliasing every package as a check forces `nix flake check` to + # realize each one: each leaf is a derivation, which is exactly what a + # check must be. This is what makes the §T6 promise — "every package + # BUILDS from a bare checkout" — true. + checks = self.packages; + }; +} diff --git a/tools/flake-gate/moon.yml b/tools/flake-gate/moon.yml new file mode 100644 index 00000000..71c5e926 --- /dev/null +++ b/tools/flake-gate/moon.yml @@ -0,0 +1,94 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# The repo-root flake gate (design record compass-distribution §T6). Two checks +# on the /flake.nix distribution surface: +# +# flake-check `nix flake check` — the flake evaluates and every package +# (compass{,-server,-runner,-stack}, compass-app, the +# compass-stack-env) builds from a bare checkout. +# flake-parity the nixpkgs-pin parity gate (tools/toolchain/flake-parity.ts): +# flake.lock's nixpkgs rev must equal devenv.lock's. The flake +# carries its OWN lock, a SECOND independent nixpkgs pin, so a +# devenv pin bump silently skews it — this gate turns the drift +# into a red check, keeping the flake-built ≡ bundle-built "one +# closure" claim honest. (The parity script's PURE core is +# unit-tested under toolchain-parity:test.) +# +# A moon project, not a bare workflow step, so both checks ride the affected- +# gated pre-merge gate: on a PR `moon ci :ci` runs them only when the PR affects +# the flake's build closure (the `inputs` below — flake.nix/flake.lock plus the +# go/ module the packages build and devenv.lock, whose bump is the drift event), +# and every push to main runs them unconditionally. devenv.lock is in the +# trigger set precisely because a devenv pin bump is what causes the skew. +# +# A nix-source project like its siblings (agent-image, guest-image, app-bundle): +# ci-group.nix, and the tag-bun install / whole-repo lint+format are never +# inherited here. +layer: 'tool' +language: 'nix' +tags: ['ci-group.nix'] + +# The ci-matrix generator computes a PR's affected set with +# `moon query projects --affected` (tools/ci-matrix/index.ts:224), which walks +# the PROJECT graph only — a project is affected when its own source tree or a +# project it `dependsOn` changed. It NEVER consults a project's cross-tree task +# `inputs` globs (.github/workflows/ci.yml:1498-1505). flake-gate's entire +# trigger surface lives in OTHER projects' trees (flake.nix/flake.lock/ +# devenv.lock in `root`, go/** in `compass-go`, the parity sources in +# `toolchain-parity`), so without these edges the gate is marked affected only +# when tools/flake-gate/moon.yml itself changes — never for the drift it exists +# to catch. Declaring the owners as deps lets `--downstream direct` pull +# flake-gate into the affected set whenever any owner is affected. The `inputs:` +# globs on flake-check remain the true closure (main-sweep + local `moon ci`). +dependsOn: + - 'root' + - 'compass-go' + - 'toolchain-parity' + +workspace: + inheritedTasks: + exclude: ['install', 'lint', 'format'] + +tasks: + flake-check: + # Evaluate + build every flake output. Runs from the workspace root where + # flake.nix lives. cache:false — nix owns its store-path caching; moon must + # not false-green a check it did not re-run (the posture guest-image and + # app-bundle take). + command: 'nix flake check' + options: + runFromWorkspaceRoot: true + cache: false + runInCI: true + # Affected-detection closure for the whole gate (the deps below inherit it + # via the project). A change to any reschedules the gate under per-PR + # affected detection; every push to main runs it unconditionally (ci.yml's + # affected-PR / full-main split). devenv.lock is in the set precisely + # because a devenv pin bump is the drift event the parity dep catches. A + # leading `/` is workspace-root-relative (the sibling nix projects' + # convention, guest-image/moon.yml:59-63). + inputs: + - '/flake.nix' + - '/flake.lock' + - '/devenv.lock' + - '/go/**' + - '/tools/toolchain/gtk-closure.nix' + - '/tools/toolchain/flake-parity.ts' + - '/tools/toolchain/flake-parity-core.ts' + + flake-parity: + # The nixpkgs-pin parity gate. bun runs the thin shell from the workspace + # root (it reads /flake.lock + /devenv.lock relative to the repo root). + # cache:false: a fail-closed gate never rides a cached green from another + # checkout. + command: 'bun tools/toolchain/flake-parity.ts' + options: + runFromWorkspaceRoot: true + cache: false + runInCI: true + + ci: + deps: ['flake-check', 'flake-parity'] + options: + cache: false + runInCI: true diff --git a/tools/toolchain/flake-parity-core.test.ts b/tools/toolchain/flake-parity-core.test.ts new file mode 100644 index 00000000..2d652698 --- /dev/null +++ b/tools/toolchain/flake-parity-core.test.ts @@ -0,0 +1,103 @@ +// Tests for the pure half of the flake nixpkgs-pin parity gate. +// +// The property under test throughout is the one the gate exists for: it must be +// CAPABLE OF FAILING on a genuine skew, and it must never turn "I could not read +// a rev" into a pass. So the extractor is tested against the real lock shape and +// against every way a rev can be absent, and the comparator against a match, a +// mismatch, and each missing side. + +import { describe, expect, test } from "bun:test"; +import { compareRevs, nixpkgsLockedRev } from "./flake-parity-core.ts"; + +// The pinned rev both locks record today (devenv.lock:190, flake.lock). +const PINNED = "c946ff36bf193309589932c371bd5ae6653c912e"; + +// A minimal flake-lock-shaped document — the `nodes.nixpkgs.locked.rev` path +// both real files carry, with the surrounding keys nix writes so the fixture is +// a realistic shape rather than only the fields read. +const lockWithRev = (rev: string): string => + JSON.stringify({ + nodes: { + nixpkgs: { + locked: { + lastModified: 1785104946, + owner: "cachix", + repo: "devenv-nixpkgs", + rev, + type: "github", + }, + original: { + owner: "cachix", + ref: "rolling", + repo: "devenv-nixpkgs", + type: "github", + }, + }, + root: { inputs: { nixpkgs: "nixpkgs" } }, + }, + root: "root", + version: 7, + }); + +describe("nixpkgsLockedRev", () => { + test("reads the nixpkgs locked rev from a lock document", () => { + expect(nixpkgsLockedRev(lockWithRev(PINNED))).toBe(PINNED); + }); + + // Every form below is a way the node can be absent. Each must yield null so + // the caller refuses rather than compares against a fabricated value — the + // false-green this gate exists to prevent. + test.each([ + ["no nixpkgs node", JSON.stringify({ nodes: { root: {} }, version: 7 })], + ["nixpkgs node without locked", JSON.stringify({ nodes: { nixpkgs: {} } })], + [ + "locked without rev", + JSON.stringify({ nodes: { nixpkgs: { locked: { owner: "cachix" } } } }), + ], + [ + "rev is not a string", + JSON.stringify({ nodes: { nixpkgs: { locked: { rev: 42 } } } }), + ], + [ + "rev is empty", + JSON.stringify({ nodes: { nixpkgs: { locked: { rev: "" } } } }), + ], + ["nodes missing entirely", JSON.stringify({ version: 7 })], + // A corrupt / merge-conflicted lock is not valid JSON — it must fail + // closed (null) rather than throw a raw SyntaxError out of the extractor. + ["source is not valid JSON", "not json{"], + ])("yields null when %s", (_label, source) => { + expect(nixpkgsLockedRev(source)).toBeNull(); + }); +}); + +describe("compareRevs", () => { + test("passes when both locks pin the same rev", () => { + expect(compareRevs(PINNED, PINNED).ok).toBe(true); + }); + + test("fails on a genuine skew, and the report names both revs", () => { + const skewed = "0000000000000000000000000000000000000000"; + const result = compareRevs(skewed, PINNED); + expect(result.ok).toBe(false); + expect(result.report).toContain(skewed); + expect(result.report).toContain(PINNED); + }); + + // A rev that could not be read is a failure, never a skip — matching + // parity-core's unverifiable-is-a-failure rule. + test("fails when the flake rev could not be read", () => { + expect(compareRevs(null, PINNED).ok).toBe(false); + }); + + test("fails when the devenv rev could not be read", () => { + expect(compareRevs(PINNED, null).ok).toBe(false); + }); + + test("names both files when neither rev could be read", () => { + const result = compareRevs(null, null); + expect(result.ok).toBe(false); + expect(result.report).toContain("flake.lock"); + expect(result.report).toContain("devenv.lock"); + }); +}); diff --git a/tools/toolchain/flake-parity-core.ts b/tools/toolchain/flake-parity-core.ts new file mode 100644 index 00000000..32899f9c --- /dev/null +++ b/tools/toolchain/flake-parity-core.ts @@ -0,0 +1,109 @@ +// Pure parsing and comparison for the flake nixpkgs-pin parity gate. No I/O, no +// process exec — total functions over strings, so the interesting half is +// unit-testable (flake-parity-core.test.ts) and the executable shell +// (flake-parity.ts) stays thin. Mirrors the parity.ts / parity-core.ts split. +// +// THE INVARIANT THIS GATE ENFORCES (design record compass-distribution §T6). The +// repo carries TWO independent nixpkgs locks: devenv.lock (the dev shell + the +// app-bundle build) and flake.lock (the repo-root flake). The "one closure" +// claim — that flake-built binaries are byte-for-byte the bundle-built ones — +// holds ONLY if the two locks resolve the same nixpkgs revision, and nothing +// enforces that by construction. A devenv pin bump silently skews flake.lock. +// This gate reads the nixpkgs revision each lock records and fails on a +// mismatch, so the drift is a red CI check rather than a silent divergence. +// +// A rev that cannot be extracted is NOT skipped — it is a failure, the same +// false-green refusal parity-core.ts makes: a gate that cannot read one side +// proves nothing. + +/** The parity verdict plus a legible one-block report of the two revs. */ +export interface FlakeParityReport { + readonly report: string; + readonly ok: boolean; +} + +/** + * Extract nixpkgs's locked revision from a flake-lock-shaped document + * (devenv.lock and flake.lock share the shape): the `nixpkgs` node's + * `locked.rev`. Narrows each step with `in`/`typeof` rather than an unchecked + * cast, so a lock whose shape moved yields null — which the caller treats as a + * failure — instead of a fabricated read. Returns null when the node or a + * non-empty string rev is absent. + */ +export function nixpkgsLockedRev(source: string): string | null { + let root: unknown; + try { + root = JSON.parse(source); + } catch { + // A corrupt / merge-conflicted lock is unreadable — route it through the + // same null→UNVERIFIABLE fail-closed path as the other malformed cases + // rather than let the SyntaxError escape as a raw stacktrace. + return null; + } + if (!isObject(root) || !("nodes" in root) || !isObject(root.nodes)) { + return null; + } + const { nodes } = root; + if (!("nixpkgs" in nodes) || !isObject(nodes.nixpkgs)) { + return null; + } + const nixpkgs = nodes.nixpkgs; + if (!("locked" in nixpkgs) || !isObject(nixpkgs.locked)) { + return null; + } + const { locked } = nixpkgs; + if ( + !("rev" in locked) || + typeof locked.rev !== "string" || + locked.rev.length === 0 + ) { + return null; + } + return locked.rev; +} + +/** Narrow an unknown to a plain record so `in`-guarded reads are checked. */ +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Decide the gate and render its report. + * + * ok is true ONLY when both revisions were extracted AND they are equal. A + * missing rev on either side is a failure (unverifiable is never a pass), and a + * mismatch names both revs so the log states which lock drifted. + */ +export function compareRevs( + flakeRev: string | null, + devenvRev: string | null, +): FlakeParityReport { + if (flakeRev === null || devenvRev === null) { + const missing = [ + flakeRev === null ? "flake.lock" : null, + devenvRev === null ? "devenv.lock" : null, + ] + .filter((s): s is string => s !== null) + .join(" and "); + return { + ok: false, + report: `UNVERIFIABLE could not read nixpkgs locked.rev from ${missing}`, + }; + } + if (flakeRev === devenvRev) { + return { + ok: true, + report: `ok flake.lock and devenv.lock both pin nixpkgs ${flakeRev}`, + }; + } + return { + ok: false, + report: [ + "MISMATCH flake.lock and devenv.lock pin different nixpkgs revisions:", + ` flake.lock ${flakeRev}`, + ` devenv.lock ${devenvRev}`, + "Re-lock the flake to the devenv.lock rev:", + " nix flake update nixpkgs (after aligning flake.nix's inputs.nixpkgs.url)", + ].join("\n"), + }; +} diff --git a/tools/toolchain/flake-parity.ts b/tools/toolchain/flake-parity.ts new file mode 100755 index 00000000..566a3703 --- /dev/null +++ b/tools/toolchain/flake-parity.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env bun +// The flake nixpkgs-pin parity gate: fail the build when the repo-root flake's +// flake.lock and devenv.lock pin different nixpkgs revisions (design record +// compass-distribution §T6). The two locks are independent, so a devenv pin bump +// silently skews the flake — this gate turns that drift into a red check. +// +// This is the thin execution shell — read the two lock files, compare, exit. All +// parsing and the pass/fail decision live in ./flake-parity-core.ts, which is +// pure and unit-tested (./flake-parity-core.test.ts). Mirrors parity.ts. +// +// Run it anywhere: in CI (moon task flake-gate:flake-parity) or locally (`bun +// tools/toolchain/flake-parity.ts`), where it should always pass since both +// locks are checked in. +// +// Exit 0 = both locks pin the same nixpkgs rev. Exit 1 = they differ OR a rev +// could not be read. Unverifiable is a failure, never a skip. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compareRevs, nixpkgsLockedRev } from "./flake-parity-core.ts"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const flakeRev = nixpkgsLockedRev( + readFileSync(join(repoRoot, "flake.lock"), "utf8"), +); +const devenvRev = nixpkgsLockedRev( + readFileSync(join(repoRoot, "devenv.lock"), "utf8"), +); + +const result = compareRevs(flakeRev, devenvRev); +console.log("flake nixpkgs pin parity — flake.lock vs devenv.lock\n"); +console.log(result.report); +process.exit(result.ok ? 0 : 1);