feat: run checks against pushes in a Sprite via `post-receive`
commit
f55ecabfeat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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