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
9 changes: 9 additions & 0 deletions .moon/workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
47 changes: 47 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -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#<pkg>` 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;
};
}
94 changes: 94 additions & 0 deletions tools/flake-gate/moon.yml
Original file line number Diff line number Diff line change
@@ -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
103 changes: 103 additions & 0 deletions tools/toolchain/flake-parity-core.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading