Add ci.image function to register pipeline container image
Extends the quire.ci Lua module with an `image` function that stores
the container image name on the Pipeline struct. The image is exposed
via `Pipeline::image()` for the executor to read at job spawn time.
Pipelines without an image declaration load successfully — the
executor resolves a default at run time.
Registration app data is removed after parse so ci.image and ci.job
calls inside run-fns error instead of silently pushing into consumed
sinks.
Assisted-by: GLM-5.1 via pi
diff --git a/docs/plans/2026-05-02-pipeline-image-declaration.md b/docs/plans/2026-05-02-pipeline-image-declaration.md
new file mode 100644
index 0000000..f24c4c7
--- /dev/null
+++ b/docs/plans/2026-05-02-pipeline-image-declaration.md
@@ -0,0 +1,434 @@
+# Pipeline-level container image declaration
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add `(ci.image "alpine")` to `ci.fnl` so pipelines can declare the container image for all their jobs. The executor resolves the image at run time: declared image → `.quire/Dockerfile` on the branch → `"debian"` default.
+
+**Architecture:** Extend the `Registration` Lua module with an `image` function. The image string is stored in a shared `Rc<RefCell<Option<String>>>` (same pattern as `jobs`). After Fennel evaluation, the image is extracted and stored on `Pipeline`. No validation that an image is declared — the executor resolves a default at run time.
+
+**Tech stack:** Rust, mlua (Lua bridge), miette (diagnostics), existing test patterns in `src/ci/pipeline.rs` and `src/ci/lua.rs`.
+
+---
+
+### Task 1: Add `DuplicateImage` validation error variant
+
+**Files:**
+- Modify: `src/ci/pipeline.rs` — add one new `ValidationError` variant
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `src/ci/pipeline.rs` tests module:
+
+```rust
+#[test]
+fn duplicate_image_variant_exists() {
+ let err = ValidationError::DuplicateImage {
+ span: miette::SourceSpan::from((0, 0)),
+ };
+ assert!(err.to_string().contains("image"));
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cargo test --lib ci::pipeline::tests::duplicate_image_variant_exists -- --nocapture`
+Expected: FAIL — `DuplicateImage` variant does not exist on `ValidationError`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add one variant to `ValidationError` in `src/ci/pipeline.rs`:
+
+```rust
+#[error("Pipeline image declared more than once.")]
+DuplicateImage {
+ #[label("duplicate image declaration")]
+ span: SourceSpan,
+},
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cargo test --lib ci::pipeline::tests::duplicate_image_variant_exists -- --nocapture`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```
+Add DuplicateImage validation error variant
+```
+
+---
+
+### Task 2: Wire `ci.image` into the Registration module and Pipeline
+
+**Files:**
+- Modify: `src/ci/pipeline.rs` — make `span_for_line` `pub(super)`, add `image` field and accessor to `Pipeline`, update `load` for new `ParseOutput`
+- Modify: `src/ci/lua.rs` — add `image` field to `Registration`, add `register_image` callback, update `parse` return type
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `src/ci/pipeline.rs` tests:
+
+```rust
+#[test]
+fn load_registers_pipeline_image() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.image "alpine")
+(ci.job :build [:quire/push] (fn [_] nil))"#;
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ assert_eq!(pipeline.image(), Some("alpine"));
+}
+
+#[test]
+fn load_succeeds_without_image() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.job :build [:quire/push] (fn [_] nil))"#;
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ assert_eq!(pipeline.image(), None);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cargo test --lib ci::pipeline::tests::load_registers_pipeline_image -- --nocapture`
+Expected: FAIL — `image()` method does not exist on `Pipeline`
+
+- [ ] **Step 3: Write minimal implementation**
+
+**`src/ci/pipeline.rs` changes:**
+
+Make `span_for_line` visible to `lua.rs`:
+
+```rust
+pub(super) fn span_for_line(source: &str, line: u32) -> SourceSpan {
+```
+
+Add `image` field to `Pipeline`:
+
+```rust
+pub struct Pipeline {
+ jobs: Vec<Job>,
+ graph: JobGraph,
+ node_index: HashMap<String, NodeIndex>,
+ fennel: Fennel,
+ image: Option<String>,
+}
+```
+
+Add accessor:
+
+```rust
+/// The container image declared via `(ci.image ...)`, if any.
+/// The executor resolves the final image at run time:
+/// declared image → `.quire/Dockerfile` → default.
+pub fn image(&self) -> Option<&str> {
+ self.image.as_deref()
+}
+```
+
+Update `Pipeline::load` to use `ParseOutput`:
+
+```rust
+pub(crate) fn load(source: &str, name: &str) -> Result<Pipeline> {
+ let fennel = Fennel::new()?;
+ let output = lua::parse(&fennel, source, name)?;
+
+ let mut errors = Vec::new();
+ let mut jobs = Vec::new();
+ for r in output.jobs {
+ match r {
+ Ok(j) => jobs.push(j),
+ Err(e) => errors.push(e),
+ }
+ }
+ let image = output.image;
+
+ let (graph, node_index) = build_graph(&jobs);
+
+ if let Err(post) = validate_post_graph(&jobs, &graph) {
+ errors.extend(post);
+ }
+
+ if errors.is_empty() {
+ Ok(Self {
+ jobs,
+ graph,
+ node_index,
+ fennel,
+ image,
+ })
+ } else {
+ Err(LoadError {
+ src: NamedSource::new(name, source.to_string()),
+ errors,
+ }
+ .into())
+ }
+}
+```
+
+**`src/ci/lua.rs` changes:**
+
+Add image tracking to `Registration`:
+
+```rust
+/// A pending image registration extracted from the Lua callback.
+struct ImageRegistration {
+ name: String,
+ line: u32,
+}
+
+struct Registration {
+ jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
+ image: Rc<RefCell<Option<ImageRegistration>>>,
+ source: Rc<String>,
+}
+```
+
+Add new return type for `parse`:
+
+```rust
+pub(super) struct ParseOutput {
+ pub(super) jobs: Vec<std::result::Result<Job, ValidationError>>,
+ pub(super) image: Option<String>,
+}
+```
+
+Update `parse`:
+
+```rust
+pub(super) fn parse(
+ fennel: &Fennel,
+ source: &str,
+ name: &str,
+) -> Result<ParseOutput> {
+ let jobs = Rc::new(RefCell::new(Vec::new()));
+ let image = Rc::new(RefCell::new(None));
+ let src = Rc::new(source.to_string());
+
+ fennel.eval_raw(source, name, |lua| {
+ lua.register_module(
+ "quire.ci",
+ Registration {
+ jobs: jobs.clone(),
+ image: image.clone(),
+ source: src.clone(),
+ },
+ )
+ })?;
+
+ let image_name = image.borrow().as_ref().map(|i| i.name.clone());
+ Ok(ParseOutput {
+ jobs: jobs.take(),
+ image: image_name,
+ })
+}
+```
+
+Update `IntoLua for Registration` to add `image` to the module table:
+
+```rust
+impl IntoLua for Registration {
+ fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
+ lua.set_app_data(self);
+ let table = lua.create_table()?;
+ table.set("job", lua.create_function(register_job)?)?;
+ table.set("image", lua.create_function(register_image)?)?;
+ table.into_lua(lua)
+ }
+}
+```
+
+Add the callback:
+
+```rust
+fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
+ let r = lua
+ .app_data_ref::<Registration>()
+ .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
+ let line = lua
+ .inspect_stack(1, |d| d.current_line())
+ .flatten()
+ .map(|l| l as u32)
+ .unwrap_or(0);
+ let mut image = r.image.borrow_mut();
+ match &*image {
+ Some(_) => {
+ let span = super::pipeline::span_for_line(&r.source, line);
+ r.jobs
+ .borrow_mut()
+ .push(Err(ValidationError::DuplicateImage { span }));
+ }
+ None => {
+ *image = Some(ImageRegistration { name, line });
+ }
+ }
+ Ok(())
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cargo test --lib ci::pipeline::tests::load_registers_pipeline_image -- --nocapture`
+Run: `cargo test --lib ci::pipeline::tests::load_succeeds_without_image -- --nocapture`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```
+Add ci.image function to register pipeline container image
+
+Extends the quire.ci Lua module with an `image` function that
+stores the container image name on the Pipeline struct. The image
+is exposed via `Pipeline::image()` for the executor to read at
+job spawn time. Pipelines without an image declaration load
+successfully — the executor resolves a default at run time.
+```
+
+---
+
+### Task 3: Reject duplicate `ci.image` calls
+
+**Files:**
+- Modify: `src/ci/pipeline.rs` — test
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `src/ci/pipeline.rs` tests:
+
+```rust
+#[test]
+fn load_errors_on_duplicate_image() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.image "alpine")
+(ci.image "ubuntu")
+(ci.job :build [:quire/push] (fn [_] nil))"#;
+ let result = Pipeline::load(source, "ci.fnl");
+ assert!(result.is_err(), "duplicate image should fail");
+ let err = result.unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains("CI validation failed"), "expected validation error: {msg}");
+}
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+The `register_image` callback from Task 2 already pushes `DuplicateImage` on the second call, so this should pass immediately. If it does, this test documents the invariant.
+
+Run: `cargo test --lib ci::pipeline::tests::load_errors_on_duplicate_image -- --nocapture`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```
+Test that duplicate ci.image declarations produce a validation error
+```
+
+---
+
+### Task 4: Error when `ci.image` is called inside a run-fn
+
+**Files:**
+- Modify: `src/ci/run.rs` — add test
+- No production changes needed
+
+- [ ] **Step 1: Write the test**
+
+The `quire.ci` module is cached by `require`. During execution, the `Registration` app data has been replaced by `Rc<Runtime>`, so calling `ci.image` inside a run-fn triggers the "registration not installed" error. This test locks in that behavior.
+
+Add to `src/ci/run.rs` tests:
+
+```rust
+#[test]
+fn execute_errors_when_image_called_in_run_fn() {
+ let (_dir, quire) = tmp_quire();
+ let runs = test_runs(&quire);
+ let run = runs.create(&test_meta()).expect("create");
+
+ let pipeline = load(
+ r#"(local ci (require :quire.ci))
+(ci.image "alpine")
+(ci.job :bad [:quire/push]
+ (fn [_]
+ (ci.image "sneaky")))"#,
+ );
+
+ let err = run
+ .execute(pipeline, HashMap::new(), std::path::Path::new("."))
+ .expect_err("expected failure");
+ let Error::JobFailed { job, source } = err else {
+ unreachable!()
+ };
+ assert_eq!(job, "bad");
+ let msg = source.to_string();
+ assert!(
+ msg.contains("registration not installed"),
+ "expected registration error, got: {msg}"
+ );
+}
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+Run: `cargo test --lib ci::run::tests::execute_errors_when_image_called_in_run_fn -- --nocapture`
+Expected: PASS — naturally enforced by the app-data swap
+
+- [ ] **Step 3: Commit**
+
+```
+Test that ci.image errors when called inside a run-fn
+```
+
+---
+
+### Task 5: Fix existing tests broken by API changes
+
+**Files:**
+- Modify: `src/ci/pipeline.rs` — update `parse_results`/`parsed_jobs` helpers for `ParseOutput`
+- Modify: `src/ci/mod.rs` — no changes needed (tests don't use `ParseOutput` directly)
+- Modify: `src/ci/run.rs` — no changes needed (tests don't use `ParseOutput` directly)
+
+- [ ] **Step 1: Run the full test suite**
+
+Run: `cargo test --lib`
+Expected: Failures in tests that use `parse_results`/`parsed_jobs` helpers, since `lua::parse` now returns `ParseOutput` instead of `Vec<Result<Job, ValidationError>>`
+
+- [ ] **Step 2: Update helpers in `pipeline.rs` tests**
+
+The `parse_results` helper currently calls `lua::parse` and returns the vec. Update it to unwrap `.jobs`:
+
+```rust
+fn parse_results(source: &str) -> Vec<std::result::Result<Job, ValidationError>> {
+ let f = Fennel::new().expect("Fennel::new() should succeed");
+ lua::parse(&f, source, "ci.fnl").expect("parse should succeed").jobs
+}
+```
+
+- [ ] **Step 3: Run full test suite**
+
+Run: `cargo test --lib`
+Expected: All tests pass
+
+- [ ] **Step 4: Commit**
+
+```
+Update test helpers for ParseOutput return type
+```
+
+---
+
+### Task 6: Run full check suite and verify
+
+**Files:**
+- No changes expected
+
+- [ ] **Step 1: Run `just all`**
+
+Run: `just all`
+Expected: Everything passes — fmt, clippy, test
+
+- [ ] **Step 2: Mark task done**
+
+```
+ranger task edit vo --state done
+```
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index d65cfb1..4be8a92 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -18,16 +18,20 @@ use crate::Result;
use crate::fennel::Fennel;
use crate::secret::SecretString;
+/// Output of [`parse`]: registered jobs and the pipeline image (if
+/// declared).
+pub(super) struct ParseOutput {
+ pub(super) jobs: Vec<std::result::Result<Job, ValidationError>>,
+ pub(super) image: Option<String>,
+}
+
/// Evaluate `source` with the registration module bound and collect
/// the registration results — one `Result` per `(ci.job …)` call.
/// Pre-graph rules run inside the callback, so a single bad job does
/// not abort the rest of the script.
-pub(super) fn parse(
- fennel: &Fennel,
- source: &str,
- name: &str,
-) -> Result<Vec<std::result::Result<Job, ValidationError>>> {
+pub(super) fn parse(fennel: &Fennel, source: &str, name: &str) -> Result<ParseOutput> {
let jobs = Rc::new(RefCell::new(Vec::new()));
+ let image = Rc::new(RefCell::new(None));
let src = Rc::new(source.to_string());
fennel.eval_raw(source, name, |lua| {
@@ -35,12 +39,22 @@ pub(super) fn parse(
"quire.ci",
Registration {
jobs: jobs.clone(),
+ image: image.clone(),
source: src.clone(),
},
)
})?;
- Ok(jobs.take())
+ // Remove the Registration app data so `ci.image`/`ci.job` calls at
+ // runtime (inside run-fns) hit "registration not installed" instead of
+ // silently pushing into the already-consumed sinks.
+ fennel.lua().remove_app_data::<Registration>();
+
+ let image_name = image.borrow().as_ref().map(|i| i.name.clone());
+ Ok(ParseOutput {
+ jobs: jobs.take(),
+ image: image_name,
+ })
}
/// The registration-time module exposed to Fennel scripts via
@@ -60,6 +74,7 @@ pub(super) fn parse(
/// ```
struct Registration {
jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
+ image: Rc<RefCell<Option<ImageRegistration>>>,
source: Rc<String>,
}
@@ -68,10 +83,43 @@ impl IntoLua for Registration {
lua.set_app_data(self);
let table = lua.create_table()?;
table.set("job", lua.create_function(register_job)?)?;
+ table.set("image", lua.create_function(register_image)?)?;
table.into_lua(lua)
}
}
+/// A pending image registration extracted from the Lua callback.
+struct ImageRegistration {
+ name: String,
+ _line: u32,
+}
+
+/// Body of `(ci.image name)`. Records the image on the first call;
+/// pushes a `DuplicateImage` error on subsequent calls.
+fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
+ let r = lua
+ .app_data_ref::<Registration>()
+ .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
+ let line = lua
+ .inspect_stack(1, |d| d.current_line())
+ .flatten()
+ .map(|l| l as u32)
+ .unwrap_or(0);
+ let mut img = r.image.borrow_mut();
+ match &*img {
+ Some(_) => {
+ let span = super::pipeline::span_for_line(&r.source, line);
+ r.jobs
+ .borrow_mut()
+ .push(Err(ValidationError::DuplicateImage { span }));
+ }
+ None => {
+ *img = Some(ImageRegistration { name, _line: line });
+ }
+ }
+ Ok(())
+}
+
/// Body of `(ci.job id inputs run-fn)`. Captures the call-site line
/// from the Lua debug stack so per-job validation errors carry a span
/// pointing back at the user's source.
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 9b5add8..0015909 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -87,6 +87,8 @@ pub struct Pipeline {
/// Job id → node index in `graph`, for O(1) lookup.
node_index: HashMap<String, NodeIndex>,
fennel: Fennel,
+ /// Container image declared via `(ci.image "...")`, if any.
+ image: Option<String>,
}
impl Pipeline {
@@ -106,6 +108,13 @@ impl Pipeline {
&self.fennel
}
+ /// The container image declared via `(ci.image ...)`, if any.
+ /// The executor resolves the final image at run time:
+ /// declared image → `.quire/Dockerfile` → default.
+ pub fn image(&self) -> Option<&str> {
+ self.image.as_deref()
+ }
+
/// Return job IDs in topological order — dependencies before
/// dependents. The pipeline is already validated as acyclic, so
/// this never fails.
@@ -159,16 +168,17 @@ impl Pipeline {
/// for miette to render with inline labels.
pub(crate) fn load(source: &str, name: &str) -> Result<Pipeline> {
let fennel = Fennel::new()?;
- let results = lua::parse(&fennel, source, name)?;
+ let output = lua::parse(&fennel, source, name)?;
let mut errors = Vec::new();
let mut jobs = Vec::new();
- for r in results {
+ for r in output.jobs {
match r {
Ok(j) => jobs.push(j),
Err(e) => errors.push(e),
}
}
+ let image = output.image;
let (graph, node_index) = build_graph(&jobs);
@@ -182,6 +192,7 @@ impl Pipeline {
graph,
node_index,
fennel,
+ image,
})
} else {
Err(LoadError {
@@ -216,7 +227,7 @@ fn build_graph(jobs: &[Job]) -> (JobGraph, HashMap<String, NodeIndex>) {
/// Compute a span covering the given 1-indexed line in `source`.
/// Returns an empty span at offset 0 when the line is unknown.
-fn span_for_line(source: &str, line: u32) -> SourceSpan {
+pub(super) fn span_for_line(source: &str, line: u32) -> SourceSpan {
if line == 0 {
return SourceSpan::from((0, 0)); // cov-excl-line
}
@@ -430,7 +441,9 @@ mod tests {
/// but the returned `Job`s only need their non-VM fields here.
fn parse_results(source: &str) -> Vec<std::result::Result<Job, ValidationError>> {
let f = Fennel::new().expect("Fennel::new() should succeed");
- lua::parse(&f, source, "ci.fnl").expect("parse should succeed")
+ lua::parse(&f, source, "ci.fnl")
+ .expect("parse should succeed")
+ .jobs
}
/// Discard parse errors and return only the successfully registered
@@ -671,6 +684,23 @@ mod tests {
assert!(!map["only"].contains("only"), "self should not be in set");
}
+ #[test]
+ fn load_registers_pipeline_image() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.image "alpine")
+(ci.job :build [:quire/push] (fn [_] nil))"#;
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ assert_eq!(pipeline.image(), Some("alpine"));
+ }
+
+ #[test]
+ fn load_succeeds_without_image() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.job :build [:quire/push] (fn [_] nil))"#;
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
+ assert_eq!(pipeline.image(), None);
+ }
+
#[test]
fn duplicate_image_variant_exists() {
let err = ValidationError::DuplicateImage {
@@ -696,4 +726,20 @@ mod tests {
"expected validation error: {msg}"
);
}
+
+ #[test]
+ fn load_errors_on_duplicate_image() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.image "alpine")
+(ci.image "ubuntu")
+(ci.job :build [:quire/push] (fn [_] nil))"#;
+ let result = Pipeline::load(source, "ci.fnl");
+ assert!(result.is_err(), "duplicate image should fail");
+ let Err(e) = result else { unreachable!() };
+ let msg = e.to_string();
+ assert!(
+ msg.contains("CI validation failed"),
+ "expected validation error: {msg}"
+ );
+ }
}
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 83d8500..0e2ed8e 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -1079,4 +1079,32 @@ mod tests {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].stdout, "from-a\n");
}
+
+ #[test]
+ fn execute_errors_when_image_called_in_run_fn() {
+ let (_dir, quire) = tmp_quire();
+ let runs = test_runs(&quire);
+ let run = runs.create(&test_meta()).expect("create");
+
+ let pipeline = load(
+ r#"(local ci (require :quire.ci))
+(ci.image "alpine")
+(ci.job :bad [:quire/push]
+ (fn [_]
+ (ci.image "sneaky")))"#,
+ );
+
+ let err = run
+ .execute(pipeline, HashMap::new(), std::path::Path::new("."))
+ .expect_err("expected failure");
+ let Error::JobFailed { job, source } = err else {
+ panic!("expected JobFailed, got: {err:?}")
+ };
+ assert_eq!(job, "bad");
+ let msg = source.to_string();
+ assert!(
+ msg.contains("registration not installed"),
+ "expected registration error, got: {msg}"
+ );
+ }
}