Pass runtime primitives as the run-fn argument
The planned jobs accessor needs per-execution scope (calling job,
transitive inputs); separating runtime primitives from the parse-time
registration is the prerequisite.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/docs/CI-FENNEL.md b/docs/CI-FENNEL.md
index d22271b..4876d53 100644
--- a/docs/CI-FENNEL.md
+++ b/docs/CI-FENNEL.md
@@ -47,26 +47,19 @@ The dependency graph is *derived* from the inputs list. No separate `:needs` fie
### Accessing inputs
-The function receives an outer table with an `:inputs` key. Standard pattern is to destructure:
+The function receives the **runtime handle** as its sole argument — a table with `sh`, `secret`, and (planned) `jobs` bound on it. Destructure the primitives the body needs:
```
-(fn [{: inputs}]
- (.. "checkout " inputs.build.sha))
+(fn [{: sh : jobs}]
+ (let [push (jobs :quire/push)]
+ (sh ["git" "checkout" push.sha])))
```
-For source inputs whose names contain `/`, the dot-access syntax is awkward. **Destructure at the function arg** — both cleaner and less error-prone:
+`(jobs name)` returns the outputs for `name` if `name` is a transitive ancestor of the calling job in the input graph; an unknown or non-ancestor name raises a Lua error. Self-lookup is rejected. Sources and jobs share one namespace — `(jobs :quire/push)` reads the source's outputs uniformly.
-```
-(fn [{:inputs {:quire/push push}}]
- (.. "checkout " push.sha))
-```
+The function-call form sidesteps the awkward dot-access on `/`-containing keys. The `:as` rebinding sugar planned for cron and cherry-picked outputs (see "Future: input args" below) layers on top of the same accessor.
-The `push` local rebinding is the recommended idiom for any source input. Use the same pattern when destructuring multiple inputs:
-
-```
-(fn [{:inputs {:quire/push push : build : compute-version}}]
- (.. "deploying " compute-version.version " from " push.sha))
-```
+> **v0 status:** the runtime handle currently carries `sh` and `secret`. The `jobs` accessor is the next addition (tracked separately); until it lands, jobs cannot read upstream outputs and `:quire/push` data is not exposed to the body.
### Sources
@@ -87,16 +80,16 @@ Every push to any ref fires a run that includes every job whose transitive input
```
(job :test-main [:quire/push]
- (fn [{:inputs {:quire/push push}}]
- (when (= "main" push.branch)
- (container {:image "rust:1.75"
- :cmd (.. "git checkout " push.sha " && cargo test")}))))
+ (fn [{: sh : jobs}]
+ (let [push (jobs :quire/push)]
+ (when (= "main" push.branch)
+ (sh (.. "git checkout " push.sha " && cargo test"))))))
(job :release [:quire/push]
- (fn [{:inputs {:quire/push push}}]
- (when (and push.tag (string.match push.tag "^v"))
- (container {:image "alpine"
- :cmd (.. "publish " push.tag)}))))
+ (fn [{: sh : jobs}]
+ (let [push (jobs :quire/push)]
+ (when (and push.tag (string.match push.tag "^v"))
+ (sh (.. "publish " push.tag))))))
```
Fennel's `when` returns `nil` if the predicate is false, otherwise the body. That nil propagates out as the `run` return value, the runner records the job as skipped. The gate and the work are in the same expression.
@@ -109,10 +102,12 @@ Source types that need configuration — cron schedules, webhook paths — can't
```
(job :nightly-audit [(cron :daily)]
- (fn [{:inputs {: cron}}] ...))
+ (fn [{: jobs}]
+ (let [tick (jobs :cron)] ...)))
(job :hourly-check [(cron :every "1h" :as :hourly)]
- (fn [{:inputs {: hourly}}] ...))
+ (fn [{: jobs}]
+ (let [tick (jobs :hourly)] ...)))
```
`cron`, `webhook`, etc. would be quire-provided functions in the eval scope. They return marker values; the runner inspects the inputs list for them, registers their event sources, instantiates runs when they fire. The `:as` keyword names the binding when the default name (the source type) would collide.
@@ -135,20 +130,21 @@ A bad `ci.fnl` push gets a CI run that fails immediately with the parse error, s
## `run` — the only primitive
-`run` is a host-side Fennel function (the container can't run Fennel) called when the job is about to execute, with all upstream inputs resolved. It returns either:
+`run` is a host-side Fennel function (the container can't run Fennel) called when the job is about to execute. It receives the runtime handle and returns either:
-* **A table** — the job's outputs. Whatever keys are in it become available to dependent jobs as `inputs.<this-job>.<key>`.
-* **`nil`** — the job is skipped. Dependents see `inputs.<this-job>` as `nil`.
+* **A table** — the job's outputs. Available to dependent jobs through `(jobs <this-job>)`.
+* **`nil`** — the job is skipped. Dependents see `(jobs <this-job>)` return `nil`.
That's the whole contract. No sugar layer, no introspection, no defaulting. The runner records what was returned.
-Inside `run`, the function uses **runtime primitives** to do work. The most important is `(container {...})`, which runs a container and returns a result table:
+Inside `run`, the function uses **runtime primitives** bound on the handle. The most important is `(container {...})`, which runs a container and returns a result table:
```
(job :test [:quire/push]
- (fn [{:inputs {:quire/push push}}]
- (container {:image "rust:1.75"
- :cmd (.. "git checkout " push.sha " && cargo test")})))
+ (fn [{: container : jobs}]
+ (let [push (jobs :quire/push)]
+ (container {:image "rust:1.75"
+ :cmd (.. "git checkout " push.sha " && cargo test")}))))
```
`(container ...)` returns `{:exit :stdout :stderr :duration}`. That's what `run` returns. The runner records it as the outputs.
@@ -157,8 +153,9 @@ For more complex jobs, the function does its own orchestration: multiple contain
```
(job :test-and-package [:quire/push]
- (fn [{:inputs {:quire/push push}}]
- (let [test (container {:image "rust:1.75"
+ (fn [{: container : jobs}]
+ (let [push (jobs :quire/push)
+ test (container {:image "rust:1.75"
:cmd ["git checkout" push.sha "&&" "cargo test"]})]
(when (= 0 test.exit)
(let [pkg (container {:image "alpine"
@@ -183,16 +180,20 @@ The residual things that *aren't* "just functions" — the inputs list and the i
## Runtime primitives
-Functions in scope inside `run`:
+Bound on the runtime handle passed into each `run` function. Destructure what you need: `(fn [{: sh : secret : jobs}] ...)`.
+* `(jobs name)` — return outputs for `name` (a transitive ancestor of the calling job, or a source ref). Errors if `name` is not in the calling job's transitive inputs.
* `(container {opts})` — run a container, return `{:exit :stdout :stderr :duration}`. Opts: `:image`, `:cmd` (string or list), `:env`, `:cwd`, `:cache` (cache dir mount, defaults to job's image-keyed cache).
* `(sh cmd)` — run a command on the host, no container. For cheap utility work. Returns the same shape as `container`.
+* `(secret name)` — resolve a named secret from the operator's config. Errors if the name isn't declared.
* `(read-file path)`, `(read-json path)`, `(write-file path content)` — workspace I/O. Paths relative to the workspace.
* `(log msg)` — append to the job's log file. Visible in the web UI.
-* `(env name)` — read an environment variable from the runner's environment (typically secrets).
+* `(env name)` — read an environment variable from the runner's environment.
Each of these blocks the Fennel function until it returns. Multi-container parallelism inside one job is a v2 want; the v1 model is "the function runs sequentially, calling primitives that block."
+> **v0 status:** `sh` and `secret` are bound today. `jobs`, `container`, `read-file`/`read-json`/`write-file`, `log`, and `env` are planned and tracked separately.
+
Eval is unsandboxed by default (see CI.md). A `run` function that loops forever or allocates without bound will hang or OOM `quire serve`. The mitigation is the same as for any Fennel hang: write `ci.fnl` thoughtfully. The bwrap opt-in (also see CI.md) covers eval and primitive calls together when it lands.
## A worked example
@@ -201,11 +202,12 @@ Eval is unsandboxed by default (see CI.md). A `run` function that loops forever
;; Helper: a parameterized test job
(fn rust-test [version]
(job (.. "test-" version) [:quire/push]
- (fn [{:inputs {:quire/push push}}]
- (when (= "main" push.branch)
- (container {:image (.. "rust:" version)
- :cmd [(.. "git checkout " push.sha)
- "cargo test --all-features"]})))))
+ (fn [{: container : jobs}]
+ (let [push (jobs :quire/push)]
+ (when (= "main" push.branch)
+ (container {:image (.. "rust:" version)
+ :cmd [(.. "git checkout " push.sha)
+ "cargo test --all-features"]}))))))
;; Matrix testing on every push to main
(each [_ v (ipairs [:1.75 :1.76 :stable])]
@@ -213,31 +215,36 @@ Eval is unsandboxed by default (see CI.md). A `run` function that loops forever
;; Build only if all tests passed
(job :build [:test-1.75 :test-1.76 :test-stable :quire/push]
- (fn [{:inputs {:quire/push push : test-1.75 : test-1.76 : test-stable}}]
- (when (and test-1.75 test-1.76 test-stable
- (= 0 test-1.75.exit)
- (= 0 test-1.76.exit)
- (= 0 test-stable.exit))
- (let [r (container {:image "rust:1.75"
- :cmd [(.. "git checkout " push.sha)
- "cargo build --release"]})]
- {:exit r.exit
- :artifacts ["target/release/quire"]}))))
+ (fn [{: container : jobs}]
+ (let [push (jobs :quire/push)
+ t-175 (jobs :test-1.75)
+ t-176 (jobs :test-1.76)
+ t-stb (jobs :test-stable)]
+ (when (and t-175 t-176 t-stb
+ (= 0 t-175.exit)
+ (= 0 t-176.exit)
+ (= 0 t-stb.exit))
+ (let [r (container {:image "rust:1.75"
+ :cmd [(.. "git checkout " push.sha)
+ "cargo build --release"]})]
+ {:exit r.exit
+ :artifacts ["target/release/quire"]})))))
;; Deploy on push to main only
(job :deploy [:build]
- (fn [{:inputs {: build}}]
- (when build
+ (fn [{: container : jobs}]
+ (when (jobs :build)
(container {:image "alpine"
:cmd "scp target/release/quire host:/usr/local/bin/"}))))
;; Tagged release: publish to a registry
(job :publish [:quire/push]
- (fn [{:inputs {:quire/push push}}]
- (when (and push.tag (string.match push.tag "^v"))
- (container {:image "rust:1.75"
- :cmd [(.. "git checkout " push.tag)
- "cargo publish"]}))))
+ (fn [{: container : jobs}]
+ (let [push (jobs :quire/push)]
+ (when (and push.tag (string.match push.tag "^v"))
+ (container {:image "rust:1.75"
+ :cmd [(.. "git checkout " push.tag)
+ "cargo publish"]})))))
```
What this expresses:
@@ -279,10 +286,11 @@ The three-context model means **`ci.fnl` is re-evaluated more than you might exp
* **Builtins live under `quire/`**; user job ids cannot contain `/`.
* **For v1, the only source is `:quire/push`.** Cron, webhook, manual deferred.
* **Filtering happens inside `run`** by returning `nil`. Every push starts a run; jobs that return nil from `run` are skipped.
-* **Destructure source inputs at the function arg** — `(fn [{:inputs {:quire/push push}}] ...)` — to avoid awkward dot-access on `/`-containing keys.
+* **Runtime handle as the run-fn argument.** The function receives a single table `{: sh : secret : jobs : container ...}` and destructures the primitives it uses. Slash-containing source names are read via the `jobs` accessor — `(jobs :quire/push)` — never via dot access.
+* **`(jobs name)` is the only accessor for upstream outputs**, covering both source refs and job outputs. Transitive ancestors are visible; non-ancestors and unknown names raise a Lua error.
* **Dependency graph derived from the inputs list**, not declared separately. No `:needs`.
* **Four structural validations**: acyclic (registration eval), non-empty inputs (registration eval), reachability from a source (registration eval), no `/` in user job ids (parse time). All fail-closed with named-target error messages.
-* **`run` is a function** `(fn [{: inputs}] ...)`. Returns a table (the outputs) or `nil` (skipped). No sugar.
+* **`run` is a function** `(fn [{: jobs ...}] ...)`. Returns a table (the outputs) or `nil` (skipped). No sugar.
* **`(container {opts})` is the primary primitive** for running containers. Opts include `:image`, so a single job can use multiple images by making multiple container calls.
* **Three eval contexts** — registration, run start, per job — all in-process inside `quire serve`. Sandboxing model and threat model are described in CI.md.
* **Source registration sourced from the default branch only** (relevant once registration becomes meaningful — for v1 it's a no-op since `:quire/push` needs no registration).
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 773de3a..1818805 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -14,8 +14,7 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
let commit = resolve_commit(maybe_sha)?;
let ci = Ci::new(repo_path);
- // Structural validation only — no need to resolve secrets.
- let Some(pipeline) = ci.load(&commit, std::collections::HashMap::new())? else {
+ let Some(pipeline) = ci.load(&commit)? else {
println!("No ci.fnl found at {}.", commit.display);
return Ok(());
};
@@ -48,14 +47,16 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
let ci = Ci::new(repo_path);
// Pull secrets from the global config; absence is fine for local
- // testing. A broken-but-present config is a real error.
+ // testing. A broken-but-present config is a real error. Secrets
+ // are passed to `Run::execute` rather than `Ci::load` since they
+ // only matter when the run-fns actually fire.
let secrets = match quire.global_config() {
Ok(c) => c.secrets,
Err(quire::Error::ConfigNotFound(_)) => std::collections::HashMap::new(),
Err(e) => return Err(e).into_diagnostic(),
};
- let Some(pipeline) = ci.load(&commit, secrets)? else {
+ let Some(pipeline) = ci.load(&commit)? else {
println!("No ci.fnl found at {}.", commit.display);
return Ok(());
};
@@ -75,7 +76,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
let mut run = runs.create(&meta)?;
println!("Run {}: executing at {}", run.id(), commit.display);
- let exec_result = run.execute(&pipeline);
+ let exec_result = run.execute(&pipeline, secrets);
for job in pipeline.jobs() {
let outputs = run.outputs(&job.id);
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index f96106e..ba3d796 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -1,10 +1,11 @@
-//! Lua bridge for `ci.fnl`: the `quire.ci` module exposed to Fennel
-//! scripts and the runtime primitives (`job`, `secret`, `sh`).
+//! Lua bridge for `ci.fnl`: the registration-time module exposed via
+//! `(require :quire.ci)` and the per-execution runtime handle passed
+//! into each job's `run-fn`.
//!
//! All mlua/Fennel interaction lives here. The pipeline module calls
//! [`parse`] to evaluate a script and collect the registered jobs;
-//! everything else (the `quire.ci` table, the primitive bodies, the
-//! Lua-side data shapes) is internal.
+//! the run module installs a [`Runtime`] and threads its handle into
+//! each `run-fn` at execute time.
use std::cell::RefCell;
use std::collections::HashMap;
@@ -17,25 +18,22 @@ use crate::Result;
use crate::fennel::Fennel;
use crate::secret::SecretString;
-/// Evaluate `source` with the `quire.ci` 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.
+/// 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,
- secrets: HashMap<String, SecretString>,
) -> Result<Vec<std::result::Result<Job, ValidationError>>> {
let jobs = Rc::new(RefCell::new(Vec::new()));
let src = Rc::new(source.to_string());
- let secrets = Rc::new(secrets);
fennel.eval_raw(source, name, |lua| {
- let module = CiModule {
+ let module = Registration {
jobs: jobs.clone(),
source: src.clone(),
- secrets: secrets.clone(),
}
.install(lua)?;
let loaded: mlua::Table = lua.globals().get::<mlua::Table>("package")?.get("loaded")?;
@@ -46,34 +44,33 @@ pub(super) fn parse(
Ok(jobs.take())
}
-/// The `quire.ci` module exposed to Fennel scripts via `require`.
+/// The registration-time module exposed to Fennel scripts via
+/// `(require :quire.ci)`.
///
-/// `install` stows the module on the Lua VM via `set_app_data`, then
-/// builds a plain table whose entries are bare functions that look the
-/// module back up at call time. Both `(ci.job …)` field access and
-/// `(local {: job : secret} (require :quire.ci))` destructuring work.
+/// `install` stows the registration sink on the Lua VM via
+/// `set_app_data` (so `register_job` can find it) and returns the
+/// `quire.ci` table — `{job}` for now. Runtime primitives (`sh`,
+/// `secret`) live on the per-execution [`Runtime`] handle, not here.
///
/// ```fennel
/// (local ci (require :quire.ci))
-/// (ci.job :build [:quire/push] (fn [_] nil))
-/// (ci.secret :github_token)
+/// (ci.job :build [:quire/push]
+/// (fn [{: sh : secret}]
+/// (sh ["echo" (secret :github_token)])))
/// ```
-struct CiModule {
+struct Registration {
jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
source: Rc<String>,
- secrets: Rc<HashMap<String, SecretString>>,
}
-impl CiModule {
- /// Install the module on `lua` as app data and return the
- /// `quire.ci` table. The registered functions error at call time
- /// if the module isn't installed first.
+impl Registration {
+ /// Install on `lua` as app data and return the `quire.ci` table.
+ /// `register_job` errors at call time if the registration isn't
+ /// installed first.
fn install(self, lua: &Lua) -> mlua::Result<mlua::Table> {
lua.set_app_data(self);
let table = lua.create_table()?;
table.set("job", lua.create_function(register_job)?)?;
- table.set("secret", lua.create_function(lookup_secret)?)?;
- table.set("sh", lua.create_function(run_sh)?)?;
Ok(table)
}
}
@@ -85,27 +82,88 @@ fn register_job(
lua: &Lua,
(id, inputs, run_fn): (String, Vec<String>, mlua::Function),
) -> mlua::Result<()> {
- let m = lua
- .app_data_ref::<CiModule>()
- .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))?;
+ 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);
- m.jobs
+ r.jobs
.borrow_mut()
- .push(Job::new(id, inputs, run_fn, line, &m.source));
+ .push(Job::new(id, inputs, run_fn, line, &r.source));
Ok(())
}
-/// Body of `(ci.secret name)`. Errors as a Lua error if the name is
-/// undeclared or the file form fails to read.
+/// Per-execution runtime: holds the secrets exposed to the job, the
+/// current-job cursor, and the per-job captured `sh` outputs.
+///
+/// `Run::execute` constructs an `Rc<Runtime>`, calls [`install`] to
+/// stow it on the Lua VM as app data and produce the handle table,
+/// and updates `current_job` around each `run_fn` call. `(sh …)` and
+/// `(secret …)` look the runtime back up via app data.
+///
+/// Outside a run, no runtime is installed; in that case `(sh …)`
+/// runs the command but doesn't record (the cursor lookup misses).
+/// `(secret …)` requires a runtime — without one, calls error.
+///
+/// [`install`]: Runtime::install
+#[derive(Debug)]
+pub(super) struct Runtime {
+ secrets: HashMap<String, SecretString>,
+ current_job: RefCell<Option<String>>,
+ outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
+}
+
+impl Runtime {
+ /// Build a fresh runtime with the given secrets. Cursor and
+ /// outputs start empty.
+ pub(super) fn new(secrets: HashMap<String, SecretString>) -> Self {
+ Self {
+ secrets,
+ current_job: RefCell::new(None),
+ outputs: RefCell::new(HashMap::new()),
+ }
+ }
+
+ /// Install on `lua` as app data and return the runtime handle —
+ /// the table passed as the sole argument to each `run_fn`.
+ pub(super) fn install(self: Rc<Self>, lua: &Lua) -> mlua::Result<mlua::Table> {
+ lua.set_app_data(self);
+ let table = lua.create_table()?;
+ table.set("sh", lua.create_function(run_sh)?)?;
+ table.set("secret", lua.create_function(lookup_secret)?)?;
+ Ok(table)
+ }
+
+ /// Mark `id` as the currently executing job. `(sh …)` invocations
+ /// from this job's `run_fn` will record output under `id`.
+ pub(super) fn enter_job(&self, id: &str) {
+ *self.current_job.borrow_mut() = Some(id.to_string());
+ }
+
+ /// Clear the current-job cursor. Subsequent `(sh …)` calls (if
+ /// any) won't be attributed to a job until `enter_job` is called again.
+ pub(super) fn leave_job(&self) {
+ *self.current_job.borrow_mut() = None;
+ }
+
+ /// Snapshot the recorded outputs for `id`. Empty if the job
+ /// produced none (or hasn't run).
+ pub(super) fn outputs(&self, id: &str) -> Vec<ShOutput> {
+ self.outputs.borrow().get(id).cloned().unwrap_or_default()
+ }
+}
+
+/// Body of `(secret name)`. Errors as a Lua error if the runtime
+/// isn't installed, the name is undeclared, or the file form fails to
+/// read.
fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
- let m = lua
- .app_data_ref::<CiModule>()
- .ok_or_else(|| mlua::Error::external("quire.ci module not installed on Lua VM"))?;
- let secret = m
+ let rt = lua
+ .app_data_ref::<Rc<Runtime>>()
+ .ok_or_else(|| mlua::Error::external("runtime not installed on Lua VM"))?;
+ let secret = rt
.secrets
.get(&name)
.ok_or_else(|| mlua::Error::external(crate::Error::UnknownSecret(name)))?;
@@ -115,7 +173,7 @@ fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
.map_err(mlua::Error::external)
}
-/// The two valid shapes of `cmd` for `(ci.sh cmd …)`. A bare string
+/// The two valid shapes of `cmd` for `(sh cmd …)`. A bare string
/// runs under `sh -c`; a sequence runs as argv with no shell.
#[derive(serde::Deserialize)]
#[serde(untagged)]
@@ -183,8 +241,7 @@ impl mlua::FromLua for Cmd {
from: "table",
to: "Cmd".into(),
message: Some(
- "ci.sh: cmd must be a non-empty sequence of strings or a shell string"
- .into(),
+ "sh: cmd must be a non-empty sequence of strings or a shell string".into(),
),
})
}
@@ -192,14 +249,14 @@ impl mlua::FromLua for Cmd {
other => Err(mlua::Error::FromLuaConversionError {
from: other.type_name(),
to: "Cmd".into(),
- message: Some("ci.sh: cmd must be a string or sequence of strings".into()),
+ message: Some("sh: cmd must be a string or sequence of strings".into()),
}),
}
}
}
-/// The optional `opts` table for `(ci.sh cmd opts?)`. Unknown keys
-/// fail closed so typos surface rather than being silently ignored.
+/// The optional `opts` table for `(sh cmd opts?)`. Unknown keys fail
+/// closed so typos surface rather than being silently ignored.
#[derive(Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ShOpts {
@@ -213,9 +270,9 @@ impl mlua::FromLua for ShOpts {
}
}
-/// The captured outcome of running a process — what `(ci.sh …)`
-/// returns. Crosses the boundary as plain serde data: `lua.to_value`
-/// on the way out, `lua.from_value` on the way in.
+/// The captured outcome of running a process — what `(sh …)` returns.
+/// Crosses the boundary as plain serde data: `lua.to_value` on the
+/// way out, `lua.from_value` on the way in.
///
/// A non-zero exit is reported in `:exit`, not raised as a Lua error —
/// matches the shape `(container …)` will eventually use so callers can
@@ -227,52 +284,16 @@ pub struct ShOutput {
pub stderr: String,
}
-/// Per-execution state the Lua bridge consults at run time.
-///
-/// `Run::execute` constructs one of these, installs it on the Lua VM
-/// via app data, and updates `current_job` around each `run_fn` call.
-/// `(ci.sh …)` reads the state on each invocation and appends its
-/// captured output to `outputs[current_job]`.
-///
-/// Outside a run (e.g. unit tests that drive `run_fn` directly), no
-/// `Rc<RuntimeState>` is installed and `(ci.sh …)` simply executes
-/// the command and returns its result without recording.
-#[derive(Debug, Default)]
-pub(super) struct RuntimeState {
- current_job: RefCell<Option<String>>,
- outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
-}
-
-impl RuntimeState {
- /// Mark `id` as the currently executing job. `(ci.sh …)` invocations
- /// from this job's `run_fn` will record output under `id`.
- pub(super) fn enter_job(&self, id: &str) {
- *self.current_job.borrow_mut() = Some(id.to_string());
- }
-
- /// Clear the current-job cursor. Subsequent `(ci.sh …)` calls (if
- /// any) won't be attributed to a job until `enter_job` is called again.
- pub(super) fn leave_job(&self) {
- *self.current_job.borrow_mut() = None;
- }
-
- /// Snapshot the recorded outputs for `id`. Empty if the job
- /// produced none (or hasn't run).
- pub(super) fn outputs(&self, id: &str) -> Vec<ShOutput> {
- self.outputs.borrow().get(id).cloned().unwrap_or_default()
- }
-}
-
-/// Body of `(ci.sh cmd opts?)`. Glue between the Lua call and
-/// `Cmd::run` — defaults the opts, runs the command, records output
-/// into the active `RuntimeState` (if any), and converts the result
-/// back to a Lua table.
+/// Body of `(sh cmd opts?)`. Glue between the Lua call and `Cmd::run`
+/// — defaults the opts, runs the command, records output into the
+/// active runtime (if any) under the current job, and converts the
+/// result back to a Lua table.
fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::Value> {
let output = cmd
.run(opts.unwrap_or_default())
.map_err(mlua::Error::external)?;
- if let Some(rt) = lua.app_data_ref::<Rc<RuntimeState>>()
+ if let Some(rt) = lua.app_data_ref::<Rc<Runtime>>()
&& let Some(job) = rt.current_job.borrow().as_ref()
{
rt.outputs
@@ -290,32 +311,40 @@ mod tests {
use super::super::pipeline::Pipeline;
use super::*;
+ /// Install a runtime with the given secrets on `pipeline`'s VM and
+ /// return the runtime handle. Mirrors what `Run::execute` does so
+ /// tests can drive a `run_fn` directly.
+ fn rt(pipeline: &Pipeline, secrets: HashMap<String, SecretString>) -> mlua::Table {
+ Rc::new(Runtime::new(secrets))
+ .install(pipeline.fennel().lua())
+ .expect("install runtime")
+ }
+
#[test]
- fn ci_secret_returns_resolved_value() {
+ fn secret_returns_resolved_value() {
let mut secrets = HashMap::new();
secrets.insert(
"github_token".to_string(),
SecretString::from_plain("ghp_test_value"),
);
let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [_] (ci.secret :github_token)))"#;
- let pipeline = Pipeline::load(source, "ci.fnl", secrets).expect("load should succeed");
+(ci.job :grab [:quire/push] (fn [{: secret}] (secret :github_token)))"#;
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
let token: String = pipeline.jobs()[0]
.run_fn
- .call(())
+ .call(rt(&pipeline, secrets))
.expect("run_fn should return the secret value");
assert_eq!(token, "ghp_test_value");
}
#[test]
- fn ci_secret_errors_for_unknown_name() {
+ fn secret_errors_for_unknown_name() {
let source = r#"(local ci (require :quire.ci))
-(ci.job :grab [:quire/push] (fn [_] (ci.secret :missing)))"#;
- let pipeline =
- Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+(ci.job :grab [:quire/push] (fn [{: secret}] (secret :missing)))"#;
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
let err = pipeline.jobs()[0]
.run_fn
- .call::<mlua::Value>(())
+ .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
.unwrap_err();
let msg = err.to_string();
assert!(
@@ -324,16 +353,15 @@ mod tests {
);
}
- /// Build a pipeline whose single job's run-fn invokes `(ci.sh …)`,
- /// invoke it, and decode the resulting Lua table as ShOutput through
- /// the pipeline's VM via `lua.from_value`.
+ /// Build a pipeline whose single job's run-fn invokes `(sh …)`,
+ /// invoke it with the runtime handle, and decode the resulting Lua
+ /// table as ShOutput through the pipeline's VM via `lua.from_value`.
fn run_sh_via_job(source: &str) -> ShOutput {
- let pipeline =
- Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
let value: mlua::Value = pipeline.jobs()[0]
.run_fn
- .call(())
- .expect("ci.sh call should return a value");
+ .call(rt(&pipeline, HashMap::new()))
+ .expect("sh call should return a value");
pipeline
.fennel()
.lua()
@@ -342,10 +370,10 @@ mod tests {
}
#[test]
- fn ci_sh_runs_argv_and_captures_stdout() {
+ fn sh_runs_argv_and_captures_stdout() {
let r = run_sh_via_job(
r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh ["echo" "hello"])))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh ["echo" "hello"])))"#,
);
assert_eq!(r.exit, 0);
assert_eq!(r.stdout, "hello\n");
@@ -353,26 +381,26 @@ mod tests {
}
#[test]
- fn ci_sh_runs_string_under_shell() {
+ fn sh_runs_string_under_shell() {
let r = run_sh_via_job(
r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hello | tr a-z A-Z")))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh "echo hello | tr a-z A-Z")))"#,
);
assert_eq!(r.exit, 0);
assert_eq!(r.stdout, "HELLO\n");
}
#[test]
- fn ci_sh_reports_nonzero_exit_without_erroring() {
+ fn sh_reports_nonzero_exit_without_erroring() {
let r = run_sh_via_job(
r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "exit 7")))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh "exit 7")))"#,
);
assert_eq!(r.exit, 7);
}
#[test]
- fn ci_sh_merges_env_into_inherited() {
+ fn sh_merges_env_into_inherited() {
// SAFETY: setting an env var in a single-threaded test process.
unsafe {
std::env::set_var("CI_SH_INHERITED_TEST", "from-parent");
@@ -380,22 +408,22 @@ mod tests {
let r = run_sh_via_job(
r#"(local ci (require :quire.ci))
(ci.job :go [:quire/push]
- (fn [_]
- (ci.sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
- {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
+ (fn [{: sh}]
+ (sh "echo $CI_SH_INHERITED_TEST $CI_SH_OVERRIDE_TEST"
+ {:env {:CI_SH_OVERRIDE_TEST "from-opts"}})))"#,
);
assert_eq!(r.exit, 0);
assert_eq!(r.stdout, "from-parent from-opts\n");
}
#[test]
- fn ci_sh_honors_cwd() {
+ fn sh_honors_cwd() {
let dir = tempfile::tempdir().expect("tempdir");
// Resolve symlinks (macOS /tmp → /private/tmp) so the assertion holds.
let canonical = fs_err::canonicalize(dir.path()).expect("canonicalize");
let source = format!(
r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "pwd" {{:cwd "{}"}})))"#,
+(ci.job :go [:quire/push] (fn [{{: sh}}] (sh "pwd" {{:cwd "{}"}})))"#,
canonical.display()
);
let r = run_sh_via_job(&source);
@@ -404,17 +432,16 @@ mod tests {
}
#[test]
- fn ci_sh_rejects_unknown_opt_key() {
+ fn sh_rejects_unknown_opt_key() {
let pipeline = Pipeline::load(
r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh "echo hi" {:cwdir "/tmp"})))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh "echo hi" {:cwdir "/tmp"})))"#,
"ci.fnl",
- HashMap::new(),
)
.expect("load should succeed");
let err = pipeline.jobs()[0]
.run_fn
- .call::<mlua::Value>(())
+ .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
.unwrap_err();
let msg = err.to_string();
assert!(
@@ -424,17 +451,16 @@ mod tests {
}
#[test]
- fn ci_sh_rejects_non_sequence_table_as_cmd() {
+ fn sh_rejects_non_sequence_table_as_cmd() {
let pipeline = Pipeline::load(
r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh {:env {:FOO "bar"}})))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh {:env {:FOO "bar"}})))"#,
"ci.fnl",
- HashMap::new(),
)
.expect("load should succeed");
let err = pipeline.jobs()[0]
.run_fn
- .call::<mlua::Value>(())
+ .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
.unwrap_err();
let msg = err.to_string();
assert!(
@@ -444,17 +470,16 @@ mod tests {
}
#[test]
- fn ci_sh_rejects_empty_argv() {
+ fn sh_rejects_empty_argv() {
let pipeline = Pipeline::load(
r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [_] (ci.sh [])))"#,
+(ci.job :go [:quire/push] (fn [{: sh}] (sh [])))"#,
"ci.fnl",
- HashMap::new(),
)
.expect("load should succeed");
let err = pipeline.jobs()[0]
.run_fn
- .call::<mlua::Value>(())
+ .call::<mlua::Value>(rt(&pipeline, HashMap::new()))
.unwrap_err();
let msg = err.to_string();
assert!(
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 90391db..6cb97e7 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -18,13 +18,11 @@ pub struct CommitRef {
pub display: String,
}
-use std::collections::HashMap;
use std::path::PathBuf;
use crate::Result;
use crate::event::{PushEvent, PushRef};
use crate::quire::Repo;
-use crate::secret::SecretString;
/// Path to the CI config within a bare repo, relative to the repo root.
pub const CI_FNL: &str = ".quire/ci.fnl";
@@ -50,23 +48,19 @@ impl Ci {
/// Load ci.fnl at a given SHA and return the validated pipeline.
///
- /// `secrets` is the map of named secrets exposed to the script via
- /// `(ci:secret …)`; pass an empty map for structural validation that
- /// does not need to resolve secrets at registration time.
+ /// Pure parse and structural validation. Secrets are not needed
+ /// here — they are passed to `Run::execute` since they only matter
+ /// when the run-fns actually fire.
///
/// Returns `Ok(None)` if the repo has no ci.fnl at that commit.
/// Errors if the Fennel source fails to parse/evaluate or if the
/// resulting job graph violates any structural rule.
- pub fn load(
- &self,
- commit: &CommitRef,
- secrets: HashMap<String, SecretString>,
- ) -> Result<Option<Pipeline>> {
+ pub fn load(&self, commit: &CommitRef) -> Result<Option<Pipeline>> {
let Some(source) = self.source(&commit.sha)? else {
return Ok(None);
};
let name = CI_FNL.to_string();
- let pipeline = Pipeline::load(&source, &name, secrets)?;
+ let pipeline = Pipeline::load(&source, &name)?;
Ok(Some(pipeline))
}
@@ -117,20 +111,8 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
}
};
- // Pull the secrets map up front; missing global config means no
- // secrets are available, but a present-but-broken config is a real
- // error and aborts the trigger.
- let secrets = match quire.global_config() {
- Ok(c) => c.secrets,
- Err(crate::Error::ConfigNotFound(_)) => HashMap::new(),
- Err(e) => {
- tracing::error!(repo = %event.repo, %e, "failed to load global config");
- return;
- }
- };
-
for push_ref in event.updated_refs() {
- if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref, secrets.clone()) {
+ if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref) {
tracing::error!(
repo = %event.repo,
sha = %push_ref.new_sha,
@@ -142,12 +124,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
}
/// Create and run CI for a single updated ref.
-fn trigger_ref(
- repo: &Repo,
- pushed_at: jiff::Timestamp,
- push_ref: &PushRef,
- secrets: HashMap<String, SecretString>,
-) -> Result<()> {
+fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> Result<()> {
let ci = repo.ci();
let Some(source) = ci.source(&push_ref.new_sha)? else {
@@ -173,7 +150,7 @@ fn trigger_ref(
let name = CI_FNL.to_string();
- match Pipeline::load(&source, &name, secrets) {
+ match Pipeline::load(&source, &name) {
Ok(_pipeline) => run.transition(RunState::Complete)?,
Err(e) => {
run.transition(RunState::Failed)?;
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 816c8b9..df01835 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -13,7 +13,6 @@ use petgraph::graph::NodeIndex;
use super::lua;
use crate::Result;
use crate::fennel::Fennel;
-use crate::secret::SecretString;
/// Edges point from dependency to dependent. Node weights are indices
/// into `Pipeline::jobs`; source refs (e.g. `quire/push`) are not nodes.
@@ -124,13 +123,9 @@ impl Pipeline {
/// then runs the post-graph rules over the surviving jobs. Any errors
/// found are gathered into a single `LoadError` carrying the source
/// for miette to render with inline labels.
- pub(crate) fn load(
- source: &str,
- name: &str,
- secrets: HashMap<String, SecretString>,
- ) -> Result<Pipeline> {
+ pub(crate) fn load(source: &str, name: &str) -> Result<Pipeline> {
let fennel = Fennel::new()?;
- let results = lua::parse(&fennel, source, name, secrets)?;
+ let results = lua::parse(&fennel, source, name)?;
let mut errors = Vec::new();
let mut jobs = Vec::new();
@@ -343,8 +338,7 @@ mod tests {
fn load_registers_a_job() {
let source = r#"(local ci (require :quire.ci))
(ci.job :test [:quire/push] (fn [_] nil))"#;
- let pipeline =
- Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
let jobs = pipeline.jobs();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].id, "test");
@@ -358,8 +352,7 @@ mod tests {
(ci.job :build [:quire/push] (fn [_] nil))
(ci.job :test [:build] (fn [_] nil))
"#;
- let pipeline =
- Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
let jobs = pipeline.jobs();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].id, "build");
@@ -376,8 +369,7 @@ mod tests {
(ci.job :sixth [:quire/push] (fn [_] nil))";
- let pipeline =
- Pipeline::load(source, "ci.fnl", HashMap::new()).expect("load should succeed");
+ let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
let lines: Vec<usize> = pipeline
.jobs()
.iter()
@@ -388,7 +380,7 @@ mod tests {
#[test]
fn load_errors_on_bad_fennel() {
- let result = Pipeline::load("{:bad {:}", "ci.fnl", HashMap::new());
+ let result = Pipeline::load("{:bad {:}", "ci.fnl");
assert!(result.is_err(), "malformed Fennel should fail");
}
@@ -398,7 +390,7 @@ 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", HashMap::new()).expect("parse should succeed")
+ lua::parse(&f, source, "ci.fnl").expect("parse should succeed")
}
/// Discard parse errors and return only the successfully registered
diff --git a/src/ci/run.rs b/src/ci/run.rs
index ffbb79f..0cb4918 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -5,13 +5,15 @@
//! parent name is the authoritative state; transitions are atomic
//! `rename` operations.
+use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use jiff::Timestamp;
-use super::lua::{RuntimeState, ShOutput};
+use super::lua::{Runtime, ShOutput};
use super::pipeline::Pipeline;
+use crate::secret::SecretString;
use crate::{Error, Result};
/// The state of a CI run.
@@ -214,10 +216,11 @@ pub struct Run {
base: PathBuf,
state: RunState,
id: String,
- /// Per-execution state shared with the Lua bridge: tracks the
- /// currently-running job and accumulates per-job `(ci.sh …)` output.
- /// Cloned (Rc) into Lua app data when `execute` runs.
- runtime: Rc<RuntimeState>,
+ /// Per-execution runtime shared with the Lua bridge: holds the
+ /// secrets exposed to the script, tracks the currently-running
+ /// job, and accumulates per-job captured `sh` output. Replaced
+ /// fresh each `execute` call so secrets are scoped to that call.
+ runtime: Rc<Runtime>,
}
impl Run {
@@ -246,7 +249,7 @@ impl Run {
base,
state,
id,
- runtime: Rc::new(RuntimeState::default()),
+ runtime: Rc::new(Runtime::new(HashMap::new())),
};
run.read_meta()?;
run.read_times()?;
@@ -255,16 +258,28 @@ impl Run {
/// Drive `pipeline` to completion through this run.
///
- /// Topo-sorts the jobs, transitions Pending → Active, then invokes
- /// each `run_fn` in dependency order. `(ci.sh …)` calls record their
- /// captured output under the current job — readable via [`outputs`]
- /// after `execute` returns. The run finishes in `Complete` if every
- /// job's `run_fn` returned without error, otherwise `Failed`.
+ /// Constructs a fresh [`Runtime`] with `secrets`, installs it on
+ /// the pipeline's Lua VM, topo-sorts the jobs, transitions
+ /// Pending → Active, then invokes each `run_fn` in dependency
+ /// order with the runtime handle as its sole argument. `(sh …)`
+ /// calls record their captured output under the current job —
+ /// readable via [`Run::outputs`] after `execute` returns. The
+ /// run finishes in `Complete` if every job's `run_fn` returned
+ /// without error, otherwise `Failed`.
///
/// Source-ref filtering (e.g. running only `quire/push`-reachable
/// jobs) is not yet implemented; for now every validated job runs.
- pub fn execute(&mut self, pipeline: &Pipeline) -> Result<()> {
- pipeline.fennel().lua().set_app_data(self.runtime.clone());
+ pub fn execute(
+ &mut self,
+ pipeline: &Pipeline,
+ secrets: HashMap<String, SecretString>,
+ ) -> Result<()> {
+ self.runtime = Rc::new(Runtime::new(secrets));
+ let rt_table = self
+ .runtime
+ .clone()
+ .install(pipeline.fennel().lua())
+ .expect("install runtime on Lua VM");
let order: Vec<String> = pipeline
.topo_order()
@@ -280,7 +295,7 @@ impl Run {
.expect("topo_order returned a job id not in pipeline");
self.runtime.enter_job(&job.id);
- let result = job.run_fn.call::<mlua::Value>(());
+ let result = job.run_fn.call::<mlua::Value>(rt_table.clone());
self.runtime.leave_job();
if let Err(e) = result {
@@ -296,8 +311,8 @@ impl Run {
Ok(())
}
- /// Snapshot the `(ci.sh …)` outputs recorded for `job_id` during
- /// the most recent `execute` call. Empty if the job hasn't run or
+ /// Snapshot the `(sh …)` outputs recorded for `job_id` during the
+ /// most recent `execute` call. Empty if the job hasn't run or
/// produced no output.
pub fn outputs(&self, job_id: &str) -> Vec<ShOutput> {
self.runtime.outputs(job_id)
@@ -549,7 +564,7 @@ mod tests {
base: PathBuf::from("/tmp/quire-test-runs/test.git"),
state: RunState::Pending,
id: uuid::Uuid::now_v7().to_string(),
- runtime: Rc::new(RuntimeState::default()),
+ runtime: Rc::new(Runtime::new(HashMap::new())),
};
let result = run.transition(RunState::Active);
@@ -641,8 +656,7 @@ mod tests {
}
fn load(source: &str) -> Pipeline {
- super::super::pipeline::Pipeline::load(source, "ci.fnl", std::collections::HashMap::new())
- .expect("load should succeed")
+ super::super::pipeline::Pipeline::load(source, "ci.fnl").expect("load should succeed")
}
#[test]
@@ -653,11 +667,11 @@ mod tests {
let pipeline = load(
r#"(local ci (require :quire.ci))
-(ci.job :a [:quire/push] (fn [_] (ci.sh ["echo" "from-a"])))
-(ci.job :b [:a] (fn [_] (ci.sh ["echo" "from-b"])))"#,
+(ci.job :a [:quire/push] (fn [{: sh}] (sh ["echo" "from-a"])))
+(ci.job :b [:a] (fn [{: sh}] (sh ["echo" "from-b"])))"#,
);
- run.execute(&pipeline).expect("execute");
+ run.execute(&pipeline, HashMap::new()).expect("execute");
assert_eq!(run.state(), RunState::Complete);
@@ -681,13 +695,13 @@ mod tests {
let log_str = log.to_string_lossy();
let source = format!(
r#"(local ci (require :quire.ci))
-(ci.job :b [:a] (fn [_] (ci.sh (.. "echo b >> {log}"))))
-(ci.job :a [:quire/push] (fn [_] (ci.sh (.. "echo a >> {log}"))))"#,
+(ci.job :b [:a] (fn [{{: sh}}] (sh (.. "echo b >> {log}"))))
+(ci.job :a [:quire/push] (fn [{{: sh}}] (sh (.. "echo a >> {log}"))))"#,
log = log_str
);
let pipeline = load(&source);
- run.execute(&pipeline).expect("execute");
+ run.execute(&pipeline, HashMap::new()).expect("execute");
let contents = fs_err::read_to_string(&log).expect("read log");
assert_eq!(contents, "a\nb\n");
@@ -702,10 +716,12 @@ mod tests {
let pipeline = load(
r#"(local ci (require :quire.ci))
(ci.job :a [:quire/push] (fn [_] (error "boom")))
-(ci.job :b [:a] (fn [_] (ci.sh ["echo" "should-not-run"])))"#,
+(ci.job :b [:a] (fn [{: sh}] (sh ["echo" "should-not-run"])))"#,
);
- let err = run.execute(&pipeline).expect_err("expected failure");
+ let err = run
+ .execute(&pipeline, HashMap::new())
+ .expect_err("expected failure");
assert!(matches!(err, Error::JobFailed { ref job, .. } if job == "a"));
assert_eq!(run.state(), RunState::Failed);
assert!(