Add `quire-ci run` to execute the whole pipeline locally
`quire-ci run` walks the pipeline in topological order, invoking
each job's run-fn against a `Runtime` with a placeholder
`quire/push`, no secrets, and `<workspace>/.git` as the git dir.
Captured `(sh ...)` output is grouped by job and printed after the
run; a non-zero `(sh ...)` exit doesn't fail the run-fn but is
called out in the summary line.

Replaces the per-job `eval` sketch — `(jobs upstream)` reads
currently return Nil for everything except `quire/push` (the
runtime doesn't propagate run-fn outputs into downstream jobs'
input views), so single-job execution would have needed an
`--input` design before being useful. Whole-pipeline execution
sidesteps that — every job sees the same empty view of its
dependencies regardless — and the contract for outputs can be
designed when a real consumer needs it.

Adds `jiff` and `mlua` as workspace deps for `quire-ci` (RunMeta
synthesis and the `IntoLua` dance to install the runtime handle).

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change pzmsnnyvyuxloksmtxzztpzxoosxwnsv
commit 536e7dfcc19d2dc448ee8389607fee7f5b4649fe
author Alpha Chen <alpha@kejadlen.dev>
date
parent xqquuuyy
diff --git a/Cargo.lock b/Cargo.lock
index 3facc65..7849043 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2129,7 +2129,9 @@ version = "0.1.0"
 dependencies = [
  "clap",
  "fs-err",
+ "jiff",
  "miette",
+ "mlua",
  "quire-core",
 ]
 
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index a1970c5..f2678a7 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -6,5 +6,7 @@ edition = "2024"
 [dependencies]
 clap = { workspace = true }
 fs-err = { workspace = true }
+jiff = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
+mlua = { workspace = true }
 quire-core = { path = "../quire-core" }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 2f9744d..9654f5b 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,12 +1,22 @@
+use std::collections::HashMap;
 use std::path::PathBuf;
+use std::rc::Rc;
 
 use clap::Parser;
 use miette::IntoDiagnostic;
+use mlua::IntoLua;
+use quire_core::ci::pipeline::{self, Pipeline, RunFn};
+use quire_core::ci::run::RunMeta;
+use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeHandle, ShOutput};
 
-/// Validate a quire CI pipeline.
+/// Run a quire CI pipeline locally.
 #[derive(Parser)]
 #[command(version, propagate_version = true)]
 struct Cli {
+    /// Workspace root containing .quire/ci.fnl. Defaults to cwd.
+    #[arg(short, long, default_value = ".", global = true)]
+    workspace: PathBuf,
+
     #[command(subcommand)]
     command: Commands,
 }
@@ -14,26 +24,29 @@ struct Cli {
 #[derive(clap::Subcommand)]
 enum Commands {
     /// Compile and validate a ci.fnl pipeline.
-    Validate {
-        /// Workspace root containing .quire/ci.fnl. Defaults to cwd.
-        #[arg(short, long, default_value = ".")]
-        workspace: PathBuf,
-    },
+    Validate,
+
+    /// Run the whole pipeline against the workspace, in topo order.
+    ///
+    /// Synthesizes a placeholder `quire/push` and runs with no
+    /// secrets — `(secret :name)` calls error, and `(jobs upstream)`
+    /// reads return Nil for everything except `quire/push` (the
+    /// runtime doesn't yet propagate run-fn outputs into downstream
+    /// jobs' input views).
+    Run,
 }
 
 fn main() -> miette::Result<()> {
     miette::set_panic_hook();
-    run()
-}
-
-fn run() -> miette::Result<()> {
     let cli = Cli::parse();
-    let Commands::Validate { workspace } = cli.command;
-
-    let path = workspace.join(".quire").join("ci.fnl");
-    let source = fs_err::read_to_string(&path).into_diagnostic()?;
+    match cli.command {
+        Commands::Validate => validate(cli.workspace),
+        Commands::Run => run_pipeline(cli.workspace),
+    }
+}
 
-    let pipeline = quire_core::ci::pipeline::compile(&source, &path.display().to_string())?;
+fn validate(workspace: PathBuf) -> miette::Result<()> {
+    let pipeline = compile_at(&workspace)?;
 
     let jobs = pipeline.jobs();
     if jobs.is_empty() {
@@ -56,3 +69,125 @@ fn run() -> miette::Result<()> {
     println!("\nAll validations passed.");
     Ok(())
 }
+
+fn run_pipeline(workspace: PathBuf) -> miette::Result<()> {
+    let pipeline = compile_at(&workspace)?;
+
+    let job_ids: Vec<String> = pipeline
+        .topo_order()
+        .into_iter()
+        .map(|s| s.to_string())
+        .collect();
+    if job_ids.is_empty() {
+        println!("No jobs registered.");
+        return Ok(());
+    }
+
+    let meta = RunMeta {
+        sha: "0".repeat(40),
+        r#ref: "HEAD".to_string(),
+        pushed_at: jiff::Timestamp::now(),
+    };
+
+    let git_dir = workspace.join(".git");
+    let runtime = Rc::new(Runtime::new(
+        pipeline,
+        HashMap::new(),
+        &meta,
+        &git_dir,
+        workspace,
+    ));
+
+    // Install the runtime handle on the Lua VM once for the whole run;
+    // each job's run-fn receives `rt_value` as its sole argument.
+    let lua = runtime.lua();
+    let rt_value = RuntimeHandle(runtime.clone())
+        .into_lua(lua)
+        .expect("install runtime on Lua VM");
+
+    let mut failed_job: Option<(String, RuntimeError)> = None;
+    for job_id in &job_ids {
+        let run_fn = runtime
+            .job(job_id)
+            .expect("topo_order returns valid ids")
+            .run_fn
+            .clone();
+
+        runtime.enter_job(job_id);
+        let result: Result<(), RuntimeError> = match run_fn {
+            RunFn::Lua(f) => f
+                .call::<mlua::Value>(rt_value.clone())
+                .map(|_| ())
+                .map_err(RuntimeError::from),
+            RunFn::Rust(f) => f(&runtime),
+        };
+        runtime.leave_job();
+
+        if let Err(e) = result {
+            failed_job = Some((job_id.clone(), e));
+            break;
+        }
+    }
+
+    lua.remove_app_data::<Rc<Runtime>>();
+
+    let outputs = runtime.take_outputs();
+    print_outputs(&job_ids, &outputs);
+
+    if let Some((job, err)) = failed_job {
+        eprintln!("\nJob '{job}' failed.");
+        return Err(err.into());
+    }
+
+    let nonzero: Vec<&str> = job_ids
+        .iter()
+        .filter(|id| {
+            outputs
+                .get(id.as_str())
+                .is_some_and(|os| os.iter().any(|o| o.exit != 0))
+        })
+        .map(String::as_str)
+        .collect();
+    if nonzero.is_empty() {
+        println!("\nAll jobs passed.");
+    } else {
+        println!(
+            "\n{} job(s) had non-zero `(sh ...)` exits: {}",
+            nonzero.len(),
+            nonzero.join(", ")
+        );
+    }
+    Ok(())
+}
+
+/// Read and compile the ci.fnl at `<workspace>/.quire/ci.fnl`.
+fn compile_at(workspace: &std::path::Path) -> miette::Result<Pipeline> {
+    let path = workspace.join(".quire").join("ci.fnl");
+    let source = fs_err::read_to_string(&path).into_diagnostic()?;
+    Ok(pipeline::compile(&source, &path.display().to_string())?)
+}
+
+/// Print captured `(sh …)` outputs, grouped by job, in execution
+/// order. Skips jobs with no recorded output.
+fn print_outputs(job_ids: &[String], outputs: &HashMap<String, Vec<ShOutput>>) {
+    for job_id in job_ids {
+        let Some(job_outputs) = outputs.get(job_id) else {
+            continue;
+        };
+        if job_outputs.is_empty() {
+            continue;
+        }
+        println!("==> {job_id}");
+        for o in job_outputs {
+            if !o.stdout.is_empty() {
+                print!("{}", o.stdout);
+            }
+            if !o.stderr.is_empty() {
+                eprint!("{}", o.stderr);
+            }
+            if o.exit != 0 {
+                eprintln!("(exit {})", o.exit);
+            }
+        }
+    }
+}