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
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);
+ }
+ }
+ }
+}