Skip to content

Commit d1f27b2

Browse files
authored
Merge pull request #1 from compoundingtech/add-nix-flake-and-ci
Add Nix flake + `nix flake check` CI
2 parents 6ff1f91 + 3bcc82d commit d1f27b2

10 files changed

Lines changed: 585 additions & 12 deletions

File tree

.github/workflows/nix.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Nix
2+
on:
3+
pull_request:
4+
push:
5+
branches: [main]
6+
7+
jobs:
8+
check:
9+
runs-on: ubuntu-latest
10+
timeout-minutes: 30
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: DeterminateSystems/determinate-nix-action@v3
14+
# `nix flake check` rather than `nix build`: it builds the package *and*
15+
# evaluates every `checks.*`, so fmt, clippy, the hermetic test suite, and
16+
# the `--help` smoke test all gate the PR from one entrypoint.
17+
- run: nix flake check --print-build-logs

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
/target
22

3+
# Nix build symlink.
4+
/result
5+
36
# Per-agent runtime overlay (materialized per clone; machine-specific, never product).
47
.st2/
58
.claude/

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ path = "src/lib.rs"
1818
# folder watch + a sleep), so no tokio. Deps grow per milestone; M0 needs only parse + CLI.
1919
anyhow = "1"
2020
clap = { version = "4", features = ["derive"] }
21+
clap_complete = "4"
2122
kdl = "6"
2223
libc = "0.2"
2324
notify = "8"

build.rs

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
1-
//! Capture the git short-sha at build time for `st2 --version` (semver + sha, per convention).
1+
//! Bake a LocalStamp for `st2 --version` on a plain `cargo build`, per the shared
2+
//! build-versioning contract. Emitted as `ST2_BUILD_STAMP_LOCAL` — a private var
3+
//! distinct from the fleet's `CLI_BUILD_STAMP`, so the flake's authoritative
4+
//! NixStamp can never be overridden by this (see src/version.rs). A hermetic Nix
5+
//! build has no `.git`, so this yields nothing there and the NixStamp is used.
26
use std::process::Command;
37

8+
fn git(args: &[&str]) -> Option<String> {
9+
let out = Command::new("git").args(args).output().ok()?;
10+
if !out.status.success() {
11+
return None;
12+
}
13+
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
14+
(!s.is_empty()).then_some(s)
15+
}
16+
417
fn main() {
5-
let sha = Command::new("git")
6-
.args(["rev-parse", "--short", "HEAD"])
7-
.output()
8-
.ok()
9-
.filter(|o| o.status.success())
10-
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
11-
.filter(|s| !s.is_empty())
12-
.unwrap_or_else(|| "unknown".to_string());
13-
println!("cargo:rustc-env=ST2_GIT_SHA={sha}");
18+
if let Some(rev) = git(&["rev-parse", "--short", "HEAD"]) {
19+
let dirty = git(&["status", "--porcelain"]).is_some_and(|s| !s.is_empty());
20+
let commit_ts = git(&["log", "-1", "--format=%ct"])
21+
.and_then(|s| s.parse::<i64>().ok())
22+
.unwrap_or(0);
23+
// Hand-assembled JSON: the short-sha is hex so no escaping is needed, and
24+
// this avoids a build-dependency just to serialize three fields.
25+
let stamp =
26+
format!(r#"{{"type":"local","rev":"{rev}","commitTs":{commit_ts},"dirty":{dirty}}}"#);
27+
println!("cargo:rustc-env=ST2_BUILD_STAMP_LOCAL={stamp}");
28+
}
29+
// Rebuild the stamp when HEAD moves or the working tree changes (dirty flag).
1430
println!("cargo:rerun-if-changed=.git/HEAD");
1531
println!("cargo:rerun-if-changed=.git/refs");
32+
println!("cargo:rerun-if-changed=.git/index");
1633
}

flake.lock

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flake.nix

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
{
2+
description = "st2 - harness-agnostic runner: reconcile a catalog+inbox folder of agent specs, keep their ptys running, deliver messages by moving files";
3+
4+
inputs = {
5+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6+
flake-utils.url = "github:numtide/flake-utils";
7+
};
8+
9+
outputs =
10+
{
11+
self,
12+
nixpkgs,
13+
flake-utils,
14+
}:
15+
flake-utils.lib.eachDefaultSystem (
16+
system:
17+
let
18+
pkgs = import nixpkgs { inherit system; };
19+
20+
# Cargo.toml is the single source of truth for the version, so a release
21+
# bump needs no matching edit here.
22+
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version;
23+
24+
# NixStamp for the shared build-versioning contract: the flake rev is a
25+
# pure input, so baking it lets a hermetic build know its own identity
26+
# without an impure `.git` read. Same env var + JSON shape as the rest of
27+
# the fleet (TS `@overeng/utils/node/cli-version`; the otel-scrape Rust
28+
# reader) — `src/version.rs` reads it via `option_env!("CLI_BUILD_STAMP")`.
29+
# `self.shortRev`/`lastModified` are absent only for a dirty tree, where
30+
# `dirtyShortRev` and the working-tree mtime stand in and `dirty` is true.
31+
buildStamp = builtins.toJSON {
32+
type = "nix";
33+
inherit version;
34+
rev = self.shortRev or self.dirtyShortRev or "unknown";
35+
commitTs = self.lastModified or 0;
36+
dirty = !(self ? rev);
37+
};
38+
39+
completionShells = [
40+
"bash"
41+
"zsh"
42+
"fish"
43+
];
44+
45+
st2 = pkgs.rustPlatform.buildRustPackage {
46+
pname = "st2";
47+
inherit version;
48+
src = self;
49+
50+
# No git or crates.io-yanked deps in the lockfile, so the lockfile
51+
# alone pins every input reproducibly — no per-dep outputHashes, and
52+
# nothing here to hand-patch when a dep bumps.
53+
cargoLock.lockFile = ./Cargo.lock;
54+
55+
# This NixStamp is the binary's authoritative build identity; it wins
56+
# over the LocalStamp `build.rs` bakes from git (which is empty here
57+
# anyway — a flake source carries no `.git`). Reaches rustc as a plain
58+
# env var, captured at compile time by `option_env!` (see
59+
# src/version.rs). A derivation env var change rebuilds the crate.
60+
CLI_BUILD_STAMP = buildStamp;
61+
62+
# `git` is present for the tests below (they init throwaway repos);
63+
# `installShellFiles` provides `installShellCompletion`.
64+
nativeBuildInputs = [
65+
pkgs.git
66+
pkgs.installShellFiles
67+
];
68+
69+
# Completions are generated by the binary we just built (never
70+
# committed), so they cannot drift from the actual command tree —
71+
# `checks.completions` gates that.
72+
postInstall = ''
73+
${pkgs.lib.concatMapStringsSep "\n" (shell: ''
74+
$out/bin/st2 completions ${shell} > completions-${shell}
75+
'') completionShells}
76+
77+
installShellCompletion --cmd st2 \
78+
--bash completions-bash \
79+
--zsh completions-zsh \
80+
--fish completions-fish
81+
'';
82+
83+
# Run only the hermetic **unit** tests (`--lib --bins`). The integration
84+
# tests in `tests/*.rs` each assume a real environment the Nix build
85+
# sandbox deliberately lacks — `/bin/bash` + `jq` (the shipped Codex
86+
# hooks), `/usr/bin/git` on a hardcoded `PATH` (materialize's
87+
# git-worktree safety check), and a live `pty` / `convoy` / systemd
88+
# `--user` manager (the survival + render-neutrality gates). Chasing
89+
# those with per-test skips is unbounded as the suite grows, so they run
90+
# on native CI (real runner) while the flake proves the package here:
91+
# it builds, its ~150 pure unit tests pass, and `--help`/completions
92+
# smoke-test the wired binary below.
93+
cargoTestFlags = [
94+
"--lib"
95+
"--bins"
96+
];
97+
98+
# A few unit tests write under $HOME; the sandbox HOME is not writable.
99+
preCheck = "export HOME=$(mktemp -d)";
100+
101+
meta = {
102+
description = "Harness-agnostic runner over a unified catalog+inbox folder of agent specs";
103+
homepage = "https://github.com/compoundingtech/st2";
104+
license = pkgs.lib.licenses.mit;
105+
mainProgram = "st2";
106+
};
107+
};
108+
in
109+
{
110+
packages.st2 = st2;
111+
packages.default = st2;
112+
113+
# `nix flake check` is the whole CI: it builds the package — which runs
114+
# the hermetic portion of the in-tree `cargo test` suite via doCheck —
115+
# and evaluates the `--help` + completions smoke tests below.
116+
#
117+
# `cargo fmt --check` / `clippy -D warnings` are intentionally NOT gated:
118+
# this is a packaging PR on an actively-developed, hand-crafted tree, and a
119+
# repo-wide formatting/lint gate here would fight the maintainer's own
120+
# commits on every rebase. The devShell ships rustfmt + clippy for whoever
121+
# wants them.
122+
checks.st2 = st2;
123+
124+
# Smoke test that the built binary actually runs and its command tree is
125+
# wired, independent of the in-tree `cargo test`.
126+
checks.help = pkgs.runCommand "st2-help-${version}" { } ''
127+
export HOME=$(mktemp -d)
128+
${st2}/bin/st2 --help > /dev/null
129+
${st2}/bin/st2 ls --help > /dev/null
130+
touch $out
131+
'';
132+
133+
# Guards the completions contract: every shell we install still generates
134+
# a non-empty script, and fish in particular still binds to `st2` (the
135+
# name the installed st2.fish file claims). Written to files first —
136+
# clap_complete streams to stdout and panics on a `grep -q` early
137+
# pipe-close (BrokenPipe), which the real `> file` usage never hits.
138+
checks.completions = pkgs.runCommand "st2-completions-${version}" { } ''
139+
${pkgs.lib.concatMapStringsSep "\n" (shell: ''
140+
${st2}/bin/st2 completions ${shell} > ${shell}.out
141+
test -s ${shell}.out || { echo "empty ${shell} completions" >&2; exit 1; }
142+
'') completionShells}
143+
144+
grep -q 'complete -c st2' fish.out \
145+
|| { echo "fish completions do not bind to \`st2\`" >&2; exit 1; }
146+
147+
touch $out
148+
'';
149+
150+
devShells.default = pkgs.mkShell {
151+
packages = [
152+
pkgs.cargo
153+
pkgs.rustc
154+
pkgs.clippy
155+
pkgs.rustfmt
156+
pkgs.rust-analyzer
157+
pkgs.git
158+
];
159+
};
160+
}
161+
);
162+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub(crate) mod shepherd;
2929
pub mod spec;
3030
pub mod status;
3131
pub mod validate;
32+
pub mod version;
3233

3334
pub use discovery::{Discovered, SpecError, discover};
3435
pub use exec_backend::ExecBackend;

src/main.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
66
use std::time::Duration;
77

88
use anyhow::{Context, Result};
9-
use clap::{Args, Parser, Subcommand};
9+
use clap::{Args, CommandFactory, Parser, Subcommand};
1010

1111
use st2::{
1212
HostLock, Runner, SystemRunner, UpReport, detect_host, ding, discover, exec_state_dir, message,
@@ -16,7 +16,7 @@ use st2::{
1616
#[derive(Parser)]
1717
#[command(
1818
name = "st2",
19-
version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("ST2_GIT_SHA"), ")"),
19+
version = st2::version::display_version(),
2020
about = "Harness-agnostic runner over a unified catalog+inbox folder"
2121
)]
2222
struct Cli {
@@ -248,6 +248,12 @@ enum Command {
248248
#[command(flatten)]
249249
ctx: MsgCtx,
250250
},
251+
/// Print a shell completion script for `st2` to stdout (`st2 completions <bash|zsh|fish|…>`).
252+
/// Generated from the live command tree, so it never drifts from the actual flags.
253+
Completions {
254+
/// The shell to generate completions for.
255+
shell: clap_complete::Shell,
256+
},
251257
}
252258

253259
/// Shared context for message subcommands: where the catalog is, who "I" am, and the local host.
@@ -550,6 +556,13 @@ fn main() -> Result<()> {
550556
let root = catalog_arg(root)?;
551557
doctor_cmd(&root, host)
552558
}
559+
Command::Completions { shell } => {
560+
// Generate from the live command tree so the script can never drift
561+
// from the actual flags (the flake gates this at build time).
562+
let mut cmd = Cli::command();
563+
clap_complete::generate(shell, &mut cmd, "st2", &mut std::io::stdout());
564+
Ok(())
565+
}
553566
}
554567
}
555568

0 commit comments

Comments
 (0)