git-ents.gitmain
⌘K
foforge
commit f55ecab
feat: run checks against pushes in a Sprite via `post-receive`

The check set on refs/meta/checks is run after a push lands, out of the push connection. A persistent Fly.io Sprite is kept per repository so build caches survive between pushes; the pushed tree is synced in and each check runs there, with PASS/FAIL reported on the hook’s stdout. The Sprite is driven through the sprite CLI (no new Cargo dep), which reads SPRITES_TOKEN from the env the server passes down to the hook.

feat: add post-receive subcommand to git-ents-server feat: add checks runner driving a per-repo Sprite feat: add bundled hooks/post-receive installed via core.hooksPath build: install the sprite CLI in the server image Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents-server/Dockerfile @@ -16,12 +16,15 @@ # We do not need the Rust toolchain to run the binary! FROM debian:bookworm-slim AS runtime WORKDIR /app -# openssh-client provides the `ssh-keygen -Y verify` the pre-receive hook runs. +# openssh-client provides the `ssh-keygen -Y verify` the pre-receive hook runs; +# curl installs the sprite CLI the post-receive check runner drives. RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates openssh-client \ + && apt-get install -y --no-install-recommends git ca-certificates openssh-client curl \ && rm -rf /var/lib/apt/lists/* +# The sprite CLI runs the checks in a Sprite; it reads SPRITES_TOKEN from the env. +RUN curl -fsSL https://sprites.dev/install.sh | bash COPY --from=builder /app/target/release/git-ents-server /usr/local/bin COPY hooks /app/hooks -RUN chmod +x /app/hooks/pre-receive +RUN chmod +x /app/hooks/pre-receive /app/hooks/post-receive ENV GIT_ENTS_HOOKS_DIR=/app/hooks ENTRYPOINT ["/usr/local/bin/git-ents-server"]
crates/git-ents-server/src/main.rs @@ -1,6 +1,7 @@ //! Git Ents server — helpful guardians of your git trees. mod asciidoc; +mod checks; mod http; mod verify; mod web; @@ -58,6 +59,9 @@ /// Verify a signed push against the authorized signers (a git `pre-receive` /// hook). PreReceive, + /// Run the configured checks against a push in a Sprite (a git + /// `post-receive` hook). + PostReceive, } /// Shared handler state: where the bare repositories live, plus a lock that @@ -87,6 +91,15 @@ }; } + if let Some(Command::PostReceive) = args.command { + // A post-receive failure cannot undo the push; report and exit clean so + // a runner hiccup never looks like a rejected push. + if let Err(reason) = checks::post_receive() { + eprintln!("checks: {reason}"); + } + return ExitCode::SUCCESS; + } + if let Some(dir) = args.generate_man { let cmd = Args::command(); if let Err(e) = clap_mangen::generate_to(cmd, dir) {
crates/git-ents-server/src/checks.rs @@ -1,0 +1,205 @@ +//! The `post-receive` check runner: a git hook that runs the configured checks +//! against a push inside a Fly.io [Sprite]. +//! +//! Where the `pre-receive` verifier gates the push synchronously, checks run +//! *after* the refs are in. The runner reads the pushed ref updates git feeds +//! the hook on stdin, loads the check set from `refs/meta/checks`, and for each +//! updated branch runs every check in a Sprite — a persistent, hardware-isolated +//! sandbox. One Sprite is kept per repository so its filesystem (and any build +//! cache a check leaves behind) survives between pushes; the pushed tree is +//! synced into it before the checks run. Results are reported on the hook's +//! stdout, which git relays to the pusher. +//! +//! The Sprite is driven through the `sprite` CLI, which reads its `SPRITES_TOKEN` +//! from the environment the server passes down to the hook. +//! +//! [Sprite]: https://sprites.dev + +use std::io::{Read, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; + +use git_ents::checks::{self, Check}; + +/// Where the pushed tree is unpacked inside the Sprite. +const WORKDIR: &str = "/work"; + +/// Run the configured checks against the push git is reporting, returning +/// `Ok(())` once results have been printed. The ref updates are read from the +/// stdin git populates for a `post-receive` hook (`<old> <new> <ref>` lines). +/// +/// A `post-receive` exit code cannot undo refs that are already in, so a failed +/// check is reported rather than turned into an error: the function returns +/// `Err` only when the runner itself could not run (unreadable check set, an +/// unreachable Sprite), never merely because a check failed. +pub fn post_receive() -> Result<(), String> { + let repo = std::env::current_dir().map_err(|e| format!("cannot resolve repository: {e}"))?; + + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|e| format!("could not read ref updates: {e}"))?; + let updates = parse_updates(&input); + if updates.is_empty() { + return Ok(()); + } + + let checks = checks::load(&repo).map_err(|e| format!("could not read checks: {e}"))?; + if checks.is_empty() { + return Ok(()); + } + + let sprite = sprite_name(&repo); + ensure_sprite(&sprite)?; + + for update in updates { + println!( + "checks: running {} check(s) on {}", + checks.len(), + update.ref_name + ); + sync_tree(&repo, &sprite, update.new)?; + run_checks(&sprite, &checks); + } + Ok(()) +} + +/// One ref git reported as updated by the push. +struct Update<'a> { + new: &'a str, + ref_name: &'a str, +} + +/// Parse git's `<old-oid> <new-oid> <ref>` stdin into the updates worth +/// checking: branch updates with a real new tip. Deletions (a zero new oid) and +/// the `refs/meta/*` control refs (auth, the check set itself) are skipped — the +/// checks gate ordinary content, not the trust plumbing. +fn parse_updates(input: &str) -> Vec<Update<'_>> { + const ZERO: &str = "0000000000000000000000000000000000000000"; + input + .lines() + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let _old = fields.next()?; + let new = fields.next()?; + let ref_name = fields.next()?; + if new == ZERO || ref_name.starts_with("refs/meta/") { + None + } else { + Some(Update { new, ref_name }) + } + }) + .collect() +} + +/// A Sprite name derived from the repository directory, kept to the +/// `[a-z0-9-]` a Sprite name allows so the same repo reuses the same sandbox. +fn sprite_name(repo: &Path) -> String { + let stem = repo + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_else(|| "repo".into()); + let sanitized: String = stem + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let trimmed = sanitized.trim_matches('-'); + format!( + "checks-{}", + if trimmed.is_empty() { "repo" } else { trimmed } + ) +} + +/// Create the repository's Sprite if it does not already exist. `sprite create` +/// fails when the Sprite is already there, which is the steady state once the +/// first push has run, so its failure is tolerated and surfaces only later if +/// the Sprite turns out to be unreachable. +fn ensure_sprite(sprite: &str) -> Result<(), String> { + let _existing = Command::new("sprite") + .args(["create", "--skip-console", sprite]) + .output() + .map_err(|e| format!("could not run the sprite CLI (is it installed?): {e}"))?; + Ok(()) +} + +/// Stream the pushed tree at `new` into the Sprite's [`WORKDIR`], replacing any +/// previous contents while leaving the rest of the persistent filesystem (build +/// caches and the like) intact. `git archive` emits the tree as a tar that the +/// Sprite unpacks over stdin. +fn sync_tree(repo: &Path, sprite: &str, new: &str) -> Result<(), String> { + let archive = Command::new("git") + .arg("-C") + .arg(repo) + .args(["archive", "--format=tar", new]) + .output() + .map_err(|e| format!("could not run git archive: {e}"))?; + if !archive.status.success() { + return Err(format!("git archive failed for {new}")); + } + + let script = format!("rm -rf {WORKDIR} && mkdir -p {WORKDIR} && tar -x -C {WORKDIR}"); + let mut child = Command::new("sprite") + .args(["exec", "-s", sprite, "sh", "-c", &script]) + .stdin(Stdio::piped()) + .spawn() + .map_err(|e| format!("could not run the sprite CLI: {e}"))?; + child + .stdin + .take() + .ok_or("sprite exec did not accept stdin")? + .write_all(&archive.stdout) + .map_err(|e| format!("could not stream the tree into the sprite: {e}"))?; + let status = child + .wait() + .map_err(|e| format!("sprite exec did not complete: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("could not unpack the tree in the sprite".to_owned()) + } +} + +/// Run each check in the Sprite's [`WORKDIR`], printing a `PASS`/`FAIL` line per +/// check and echoing the output of any that fail so the pusher sees why. +fn run_checks(sprite: &str, checks: &[Check]) { + for check in checks { + let output = Command::new("sprite") + .args([ + "exec", + "-s", + sprite, + "--dir", + WORKDIR, + "sh", + "-c", + &check.command, + ]) + .output(); + match output { + Ok(output) if output.status.success() => { + println!("checks: PASS {}", check.name); + } + Ok(output) => { + println!("checks: FAIL {} ({})", check.name, check.command); + let logs = String::from_utf8_lossy(&output.stderr); + let logs = if logs.trim().is_empty() { + String::from_utf8_lossy(&output.stdout) + } else { + logs + }; + for line in logs.lines() { + println!("checks: {line}"); + } + } + Err(e) => { + println!("checks: ERROR {} (could not run: {e})", check.name); + } + } + } +}
hooks/post-receive @@ -1,0 +1,4 @@ +#!/bin/sh +# Run the configured checks (refs/meta/checks) against the push in a Sprite. +# Installed on every served repository via `core.hooksPath`; see `checks.rs`. +exec git-ents-server post-receive