Add ci.mirror helper for internal mirror jobs
Replaces the twelve-line auth+env-var dance in .quire/ci.fnl for
GitHub mirroring with a (ci.mirror url opts) call that registers a
single internal job at quire/mirror. The run-fn is plain Rust; the
:tag callback is the only Lua-typed opt, called at execute time to
produce the tag name. Singleton — DuplicateMirror on second call.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/docs/plans/2026-05-03-ci-mirror-helper-design.md b/docs/plans/2026-05-03-ci-mirror-helper-design.md
new file mode 100644
index 0000000..b2d5605
--- /dev/null
+++ b/docs/plans/2026-05-03-ci-mirror-helper-design.md
@@ -0,0 +1,148 @@
+# `(ci.mirror …)` helper
+
+**Goal:** Add a high-level Fennel helper that registers a single
+internal job to mirror push refs to a remote git URL. Compresses the
+twelve-line auth/env-var dance from `docs/plans/2026-04-29-ci-fnl-mirror-design.md`
+into a one-line declaration and insulates the user from the v0 → container
+migration.
+
+**Status:** Depends on the internal-jobs foundation (separate design,
+not yet written) — `:quire/`-prefixed jobs registered by quire itself,
+plus the `(output key value)` runtime primitive. This doc specifies
+the helper's surface only.
+
+## Surface
+
+```fennel
+(ci.mirror "https://github.com/owner/repo.git"
+ {:secret :github_token
+ :after [:build]})
+```
+
+Two arguments:
+
+1. **URL** (string) — the remote to push to. Required.
+2. **Options** (table):
+ - `:secret <name>` — name in the global `:secrets` map. Required.
+ Resolved at run time. Auth-less remotes are not yet supported.
+ - `:tag <fn>` — required callback that returns the tag name. Called
+ at execute time with the push table (`{: sha : ref : pushed-at :
+ git-dir}`); the return value is the tag name applied to
+ `push.sha` and pushed alongside the refs. Lets the operator
+ encode their own tag scheme without the helper baking one in.
+
+ Example:
+
+ ```fennel
+ :tag (fn [{: sha}]
+ (.. "v" (os.date "!%Y-%m-%d") "-" (string.sub sha 1 8)))
+ ```
+ - `:refs <list>` — refs to push. Defaults to `[]`, which means "push
+ the triggering ref" (`push.ref`). A non-empty list pushes those
+ refs verbatim regardless of which ref triggered the run.
+ - `:after <list>` — extra job dependencies for sequencing.
+ Defaults to `[]`. The mirror always depends on `:quire/push`
+ internally; `:after` adds further upstream jobs the mirror should
+ wait on (e.g. `[:build]` so a failed build skips the mirror).
+ - `:as <id>` — alternate internal-job id. Defaults to
+ `quire/mirror`. Reserved for the multi-mirror case; not exercised
+ in v0.
+
+The auth flow is hardcoded to GitHub-style HTTP Basic with
+`x-access-token` username, base64-encoded into an
+`http.extraheader` config. Add a `:auth` knob when a second forge
+actually needs a different shape.
+
+## Singleton
+
+Calling `(ci.mirror …)` twice in the same `ci.fnl` is a registration
+error: `DefinitionError::DuplicateMirror`. Same shape as
+`DuplicateImage` — pipeline-level singleton, span on the duplicate
+call. The `:as` opt-out exists for the rare multi-mirror case but is
+deferred until that case shows up in practice.
+
+## Desugaring
+
+The helper registers a single internal job at id `quire/mirror`,
+inputs `[:quire/push, …after]`, with a Rust-implemented run-fn that:
+
+1. Reads `push.sha`, `push.ref`, and `push.git-dir` from
+ `(jobs :quire/push)`.
+2. Resolves the secret named by `:secret` from the global secrets map.
+3. Calls `:tag` with the push table to get the tag name, then
+ `git tag <name> <sha>` locally. Tagging failure is a job error.
+4. Builds the auth header (base64 of `x-access-token:<token>` as
+ HTTP Basic).
+5. Spawns `git push <url> <refspecs…> refs/tags/<tag>` where
+ `<refspecs…>` is `:refs` if non-empty, otherwise just `push.ref`.
+ `GIT_DIR` and the `http.extraheader` config are set via env.
+6. Records the result(s) via the runtime's sh-capture channel so they
+ show up in the run log alongside any other shell output.
+
+For v0 the recorded output flows through the existing sh-capture map
+(used for log streaming). When the `(output …)` primitive lands as
+part of the foundation work, the helper switches to publishing
+structured outputs (`:tag-name`, `:tag-result`, `:push-result`) that
+downstream jobs can read via `(jobs :quire/mirror)`.
+
+## Failure modes
+
+Registration-time errors land as `DefinitionError`s, rendered with a
+span at the call site via miette:
+
+- `DuplicateMirror` — second `(ci.mirror …)` call.
+- `InvalidMirrorCall { message }` — opt-shape problems caught at
+ registration: missing `:tag`, missing `:secret`, unknown opt key
+ (e.g. typo'd `:tagPrefix`), `:tag` not a function. Note: these
+ check the call shape, not the contents. Whether the named secret
+ exists in the global config is checked at run time and surfaces as
+ `Error::UnknownSecret` then.
+
+Run-time failures (network, auth rejection, push rejection) surface
+as a non-zero `:exit` in the recorded output, the same as any `(sh
+…)` failure. The job is marked failed by the executor's existing
+non-zero-exit handling; mirror status is visible in the run log.
+
+## Migration from raw `(sh …)`
+
+The current single mirror in `.quire/ci.fnl` is the twelve-line form
+in `docs/plans/2026-04-29-ci-fnl-mirror-design.md` lines 22–35.
+After this lands, that becomes:
+
+```fennel
+(ci.mirror "https://github.com/owner/repo.git"
+ {:secret :github_token
+ :tag (fn [{: sha}]
+ (.. "v" (os.date "!%Y-%m-%d") "-" (string.sub sha 1 8)))})
+```
+
+No backward-compatibility shim. The repo using the raw form gets
+updated by hand in the same change. One operator, one repo, no
+stakes.
+
+## What this doesn't cover
+
+- The internal-job mechanism (`:quire/`-namespaced jobs registered
+ by quire, exempt from `EmptyInputs`/`ReservedSlash` user-facing
+ rules) — separate design.
+- The `(output key value)` runtime primitive — same separate design.
+- Turning `:quire/push` into a real graph node — same separate
+ design.
+- Container-era changes. The helper's *implementation* will change
+ when CI moves to containers (different git invocation, secret
+ injection mechanism), but the surface above stays stable. That's
+ the whole point of having a helper.
+
+## Open questions
+
+1. **`--mirror`-style "push everything" semantics.** Listing every
+ ref by hand in `:refs` is workable for one or two named refs but
+ awkward at scale. If a future use case wants "send all refs and
+ delete remote refs that disappeared," add a sentinel (`:all`,
+ `:mirror`, or similar) that maps to `git push --mirror`. Not
+ needed today; one operator, one repo, named refs.
+
+2. **SSH / non-HTTPS remotes.** A bare `(ci.mirror
+ "git@host:foo.git")` could imply ssh-with-host-keys. Probably
+ overscope for v0 — require `:secret` and only support HTTPS for
+ now. Revisit when a non-HTTPS use case shows up.
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index 1e4ef96..8581468 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -17,6 +17,7 @@ use miette::NamedSource;
use super::pipeline::{DefinitionError, Diagnostic, Job, PipelineError, RunFn};
use crate::Result;
+use crate::error::Error;
use crate::fennel::Fennel;
use crate::secret::SecretString;
@@ -39,6 +40,7 @@ pub(super) struct Registrations {
pub(super) fn register(fennel: &Fennel, source: &str, name: &str) -> Result<Registrations> {
let jobs: Rc<RefCell<Vec<Job>>> = Rc::new(RefCell::new(Vec::new()));
let image = Rc::new(RefCell::new(None));
+ let mirror = Rc::new(RefCell::new(None));
let src = Rc::new(source.to_string());
let errors = Rc::new(RefCell::new(Vec::new()));
@@ -50,6 +52,7 @@ pub(super) fn register(fennel: &Fennel, source: &str, name: &str) -> Result<Regi
jobs: jobs.clone(),
errors: errors.clone(),
image: image.clone(),
+ mirror: mirror.clone(),
source: src.clone(),
},
)
@@ -95,6 +98,7 @@ struct Registration {
jobs: Rc<RefCell<Vec<Job>>>,
errors: Rc<RefCell<Vec<DefinitionError>>>,
image: Rc<RefCell<Option<ImageRegistration>>>,
+ mirror: Rc<RefCell<Option<MirrorRegistration>>>,
source: Rc<String>,
}
@@ -104,6 +108,7 @@ impl IntoLua for Registration {
let table = lua.create_table()?;
table.set("job", lua.create_function(register_job)?)?;
table.set("image", lua.create_function(register_image)?)?;
+ table.set("mirror", lua.create_function(register_mirror)?)?;
table.into_lua(lua)
}
}
@@ -114,6 +119,14 @@ struct ImageRegistration {
_line: u32,
}
+/// Marker that `(ci.mirror …)` was called. Held only so a second
+/// call can produce `DuplicateMirror`; the resulting job is built
+/// inline in `register_mirror` and pushed onto `Registration::jobs`
+/// like any other.
+struct MirrorRegistration {
+ _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<()> {
@@ -142,7 +155,9 @@ fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
/// 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.
+/// pointing back at the user's source. Enforces the user-facing
+/// reserved-slash rule: ids may not contain `/`, since the `quire/`
+/// namespace is reserved for built-in helpers (see `register_mirror`).
fn register_job(
lua: &Lua,
(id, inputs, run_fn): (String, Vec<String>, mlua::Function),
@@ -155,6 +170,15 @@ fn register_job(
.flatten()
.map(|l| l as u32)
.unwrap_or(0);
+
+ if id.contains('/') {
+ let span = super::pipeline::span_for_line(&r.source, line);
+ r.errors
+ .borrow_mut()
+ .push(DefinitionError::ReservedSlash { job_id: id, span });
+ return Ok(());
+ }
+
match Job::new(id, inputs, RunFn::Lua(run_fn), line, &r.source) {
Ok(job) => r.jobs.borrow_mut().push(job),
Err(e) => r.errors.borrow_mut().push(e),
@@ -499,7 +523,7 @@ impl mlua::FromLua for Cmd {
/// The optional `opts` table for `(sh cmd opts?)`. Unknown keys fail
/// closed so typos surface rather than being silently ignored.
-#[derive(Default, serde::Deserialize)]
+#[derive(Clone, Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ShOpts {
env: HashMap<String, String>,
@@ -551,6 +575,249 @@ fn run_sh(lua: &Lua, (cmd, opts): (Cmd, Option<ShOpts>)) -> mlua::Result<mlua::V
lua.to_value(&output)
}
+/// Parsed options from `(ci.mirror url opts)`. Captured at
+/// registration time and moved into the run-fn closure.
+struct MirrorOpts {
+ secret: String,
+ /// Refs to push to the remote. Empty means "push whatever ref
+ /// triggered the run."
+ refs: Vec<String>,
+ /// Tag callback. Called at execute time with the push table to
+ /// produce the tag name; the helper then tags `push.sha` and
+ /// pushes that tag alongside the refs.
+ tag: mlua::Function,
+ /// Extra job ids to depend on, in addition to `quire/push`.
+ after: Vec<String>,
+}
+
+/// Parse the opts table for `(ci.mirror url opts)`.
+///
+/// `:tag` is extracted manually since `mlua::Function` isn't
+/// serde-deserializable; the rest go through `lua.from_value` with
+/// `deny_unknown_fields` so typos surface as registration errors.
+///
+/// Errors are returned as `mlua::Error::external` so callers can
+/// render them via `Display` into a `DefinitionError::InvalidMirrorCall`
+/// at the call site.
+fn parse_mirror_opts(lua: &Lua, opts: mlua::Table) -> mlua::Result<MirrorOpts> {
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Fields {
+ secret: String,
+ #[serde(default)]
+ refs: Vec<String>,
+ #[serde(default)]
+ after: Vec<String>,
+ }
+
+ // Pull :tag separately — it's a Lua function, not deserializable.
+ let tag: mlua::Function = match opts.get::<mlua::Value>("tag")? {
+ mlua::Value::Function(f) => f,
+ mlua::Value::Nil => {
+ return Err(mlua::Error::external(
+ ":tag is required (a function returning the tag name)",
+ ));
+ }
+ other => {
+ return Err(mlua::Error::external(format!(
+ ":tag must be a function, got {}",
+ other.type_name()
+ )));
+ }
+ };
+
+ // Build a copy of the opts table without :tag so
+ // `deny_unknown_fields` doesn't trip on it.
+ let stripped = lua.create_table()?;
+ for pair in opts.pairs::<String, mlua::Value>() {
+ let (k, v) = pair?;
+ if k != "tag" {
+ stripped.set(k, v)?;
+ }
+ }
+
+ let fields: Fields = lua.from_value(mlua::Value::Table(stripped))?;
+
+ Ok(MirrorOpts {
+ secret: fields.secret,
+ refs: fields.refs,
+ tag,
+ after: fields.after,
+ })
+}
+
+/// Body of `(ci.mirror url opts)`. Validates singleton, parses opts,
+/// and registers a single internal job at `quire/mirror` whose run-fn
+/// performs the tag-and-push at execute time.
+fn register_mirror(lua: &Lua, (url, opts): (String, mlua::Table)) -> 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);
+
+ // Singleton check.
+ {
+ let mut m = r.mirror.borrow_mut();
+ if m.is_some() {
+ let span = super::pipeline::span_for_line(&r.source, line);
+ r.errors
+ .borrow_mut()
+ .push(DefinitionError::DuplicateMirror { span });
+ return Ok(());
+ }
+ *m = Some(MirrorRegistration { _line: line });
+ }
+
+ let parsed = match parse_mirror_opts(lua, opts) {
+ Ok(p) => p,
+ Err(e) => {
+ let span = super::pipeline::span_for_line(&r.source, line);
+ r.errors.borrow_mut().push(DefinitionError::InvalidMirrorCall {
+ message: e.to_string(),
+ span,
+ });
+ return Ok(());
+ }
+ };
+
+ // Build the run-fn as a pure Rust closure. Captures owned
+ // values so it's `'static`. The mlua::Function for `:tag`
+ // carries its own registry handle and stays callable from Rust
+ // without a `&Lua`.
+ let url_owned = url.clone();
+ let secret_name = parsed.secret;
+ let refs = parsed.refs;
+ let tag_callback = parsed.tag;
+ let run_fn = RunFn::Rust(Rc::new(move |rt: &Runtime| {
+ execute_mirror(rt, &url_owned, &secret_name, &refs, &tag_callback)
+ }));
+
+ // Inputs: always quire/push first (the push data source), then
+ // any extra dependencies from :after for sequencing.
+ let mut inputs = vec!["quire/push".to_string()];
+ inputs.extend(parsed.after);
+
+ match Job::new("quire/mirror".to_string(), inputs, run_fn, line, &r.source) {
+ Ok(job) => r.jobs.borrow_mut().push(job),
+ Err(e) => r.errors.borrow_mut().push(e),
+ }
+ Ok(())
+}
+
+/// Run-time body of the `quire/mirror` job. Reads the push data from
+/// the runtime, tags the commit using `tag_callback`, and pushes the
+/// configured refs (or the triggering ref, when `refs` is empty)
+/// alongside the tag.
+///
+/// Side effects only — outputs are recorded against the calling job
+/// via the existing sh-capture channel. Returns `Ok(())` whether or
+/// not the remote push succeeded; non-zero `git push` exit lands in
+/// the run log alongside any other shell output. Returns `Err` only
+/// for setup failures (unknown secret, failed tag, base64 spawn).
+fn execute_mirror(
+ rt: &Runtime,
+ url: &str,
+ secret_name: &str,
+ refs: &[String],
+ tag_callback: &mlua::Function,
+) -> crate::Result<()> {
+ let calling = rt.current_job.borrow();
+ let calling = calling
+ .as_ref()
+ .expect("mirror run-fn invoked without an active job");
+
+ // Pull push data from this job's inputs view. Reachability is a
+ // structural fact established at registration; the unwraps are
+ // program invariants, not user-reachable conditions.
+ let view = rt
+ .inputs
+ .get(calling)
+ .unwrap_or_else(|| unreachable!("no inputs view for calling job '{calling}'"));
+ let push_table = view
+ .get("quire/push")
+ .and_then(|v| v.as_ref())
+ .and_then(|v| v.as_table())
+ .expect("quire/push table absent from quire/mirror inputs view");
+ let sha: String = push_table.get("sha")?;
+ let pushed_ref: String = push_table.get("ref")?;
+ let git_dir: String = push_table.get("git-dir")?;
+
+ // Resolve the access token.
+ let secret = rt
+ .secrets
+ .get(secret_name)
+ .ok_or_else(|| Error::UnknownSecret(secret_name.to_string()))?
+ .reveal()?
+ .to_string();
+
+ let git_opts = ShOpts {
+ env: HashMap::from([("GIT_DIR".to_string(), git_dir.clone())]),
+ cwd: None,
+ };
+
+ // Tag step.
+ let tag_name: String = tag_callback.call(push_table.clone())?;
+ let tag_cmd = Cmd::Argv {
+ program: "git".to_string(),
+ args: vec!["tag".to_string(), tag_name.clone(), sha.clone()],
+ };
+ let tag_result = tag_cmd.run(git_opts.clone())?;
+ let tag_failed = tag_result.exit != 0;
+ let tag_stderr = tag_result.stderr.clone();
+ record_output(rt, calling, tag_result);
+ if tag_failed {
+ return Err(Error::Git(format!("git tag failed: {}", tag_stderr.trim())));
+ }
+
+ // Build the auth header. printf-into-base64 keeps the secret out
+ // of the argv (visible in `ps`); piping via $T is the smallest
+ // stdin-free alternative.
+ let token_pair = format!("x-access-token:{secret}");
+ let encoded_output = Cmd::Shell("printf '%s' \"$T\" | base64 --wrap=0".to_string()).run(
+ ShOpts {
+ env: HashMap::from([("T".to_string(), token_pair)]),
+ cwd: None,
+ },
+ )?;
+ let auth_header = format!("Authorization: Basic {}", encoded_output.stdout.trim());
+
+ // Push the configured refs (or the trigger ref, if none) plus the tag.
+ let mut push_args = vec![
+ "-c".to_string(),
+ format!("http.extraHeader={auth_header}"),
+ "push".to_string(),
+ "--porcelain".to_string(),
+ url.to_string(),
+ ];
+ if refs.is_empty() {
+ push_args.push(pushed_ref);
+ } else {
+ push_args.extend(refs.iter().cloned());
+ }
+ push_args.push(format!("refs/tags/{tag_name}"));
+ let push_cmd = Cmd::Argv {
+ program: "git".to_string(),
+ args: push_args,
+ };
+ let push_result = push_cmd.run(git_opts)?;
+ record_output(rt, calling, push_result);
+
+ Ok(())
+}
+
+/// Record an `ShOutput` against the calling job for log streaming.
+fn record_output(rt: &Runtime, job: &str, output: ShOutput) {
+ rt.outputs
+ .borrow_mut()
+ .entry(job.to_string())
+ .or_default()
+ .push(output);
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -753,4 +1020,338 @@ mod tests {
"expected type error, got: {msg}"
);
}
+
+ /// Set up a bare git repo with one commit. Returns the tempdir,
+ /// the bare repo path, and the head SHA.
+ fn bare_repo() -> (tempfile::TempDir, std::path::PathBuf, String) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let work = dir.path().join("work");
+ let bare = dir.path().join("repo.git");
+
+ fs_err::create_dir_all(&work).expect("mkdir work");
+ let env_vars: [(&str, &str); 6] = [
+ ("GIT_AUTHOR_NAME", "test"),
+ ("GIT_AUTHOR_EMAIL", "test@test"),
+ ("GIT_COMMITTER_NAME", "test"),
+ ("GIT_COMMITTER_EMAIL", "test@test"),
+ ("GIT_CONFIG_GLOBAL", "/dev/null"),
+ ("GIT_CONFIG_SYSTEM", "/dev/null"),
+ ];
+ let output = std::process::Command::new("git")
+ .args(["init", "-b", "main"])
+ .current_dir(&work)
+ .envs(env_vars)
+ .output()
+ .expect("git init");
+ assert!(output.status.success());
+
+ let output = std::process::Command::new("git")
+ .args(["commit", "--allow-empty", "-m", "initial"])
+ .current_dir(&work)
+ .envs(env_vars)
+ .output()
+ .expect("git commit");
+ assert!(output.status.success());
+
+ let output = std::process::Command::new("git")
+ .args([
+ "clone",
+ "--bare",
+ work.to_str().unwrap(),
+ bare.to_str().unwrap(),
+ ])
+ .current_dir(dir.path())
+ .output()
+ .expect("git clone --bare");
+ assert!(output.status.success());
+
+ let sha_output = std::process::Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(&bare)
+ .output()
+ .expect("git rev-parse");
+ let sha = String::from_utf8(sha_output.stdout)
+ .expect("utf8")
+ .trim()
+ .to_string();
+
+ (dir, bare, sha)
+ }
+
+ /// Pull the mirror job out of a compiled pipeline. Panics if no
+ /// `quire/mirror` job is present.
+ fn mirror_job_inputs(source: &str) -> Vec<String> {
+ let pipeline = super::super::pipeline::compile(source, "ci.fnl")
+ .expect("compile should succeed");
+ pipeline
+ .jobs()
+ .iter()
+ .find(|j| j.id == "quire/mirror")
+ .expect("mirror job should be registered")
+ .inputs
+ .clone()
+ }
+
+ #[test]
+ fn mirror_registers_quire_mirror_job_with_push_input() {
+ let inputs = mirror_job_inputs(
+ r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git"
+ {:secret :github_token :tag (fn [_] "v1")})"#,
+ );
+ assert_eq!(inputs, vec!["quire/push".to_string()]);
+ }
+
+ #[test]
+ fn mirror_after_appends_to_inputs() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.job :build [:quire/push] (fn [_] nil))
+(ci.mirror "https://github.com/example/repo.git"
+ {:secret :github_token :tag (fn [_] "v1") :after [:build]})"#;
+ let inputs = mirror_job_inputs(source);
+ assert_eq!(
+ inputs,
+ vec!["quire/push".to_string(), "build".to_string()]
+ );
+ }
+
+ #[test]
+ fn mirror_duplicate_call_errors() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git" {:secret :a})
+(ci.mirror "https://github.com/example/other.git" {:secret :b})"#;
+ let Err(err) = super::super::pipeline::compile(source, "ci.fnl") else {
+ panic!("expected error");
+ };
+ let crate::Error::Pipeline(pe) = err else {
+ panic!("expected PipelineError, got {err:?}");
+ };
+ assert!(
+ pe.diagnostics.iter().any(|d| matches!(
+ d,
+ super::super::pipeline::Diagnostic::Definition(
+ DefinitionError::DuplicateMirror { .. }
+ )
+ )),
+ "expected DuplicateMirror in: {:?}",
+ pe.diagnostics
+ );
+ }
+
+ /// Compile, expect a single `InvalidMirrorCall` diagnostic, return its message.
+ fn invalid_mirror_message(source: &str) -> String {
+ let Err(err) = super::super::pipeline::compile(source, "ci.fnl") else {
+ panic!("expected error");
+ };
+ let crate::Error::Pipeline(pe) = err else {
+ panic!("expected PipelineError, got {err:?}");
+ };
+ pe.diagnostics
+ .iter()
+ .find_map(|d| match d {
+ super::super::pipeline::Diagnostic::Definition(
+ DefinitionError::InvalidMirrorCall { message, .. },
+ ) => Some(message.clone()),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("expected InvalidMirrorCall in: {:?}", pe.diagnostics))
+ }
+
+ #[test]
+ fn mirror_unknown_opt_key_errors() {
+ let msg = invalid_mirror_message(
+ r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git"
+ {:secret :a :tag (fn [_] "v1") :tagPrefix "v"})"#,
+ );
+ assert!(
+ msg.contains("tagPrefix") && msg.contains("unknown field"),
+ "expected unknown-field error mentioning the typo, got: {msg}"
+ );
+ }
+
+ #[test]
+ fn mirror_requires_secret() {
+ let msg = invalid_mirror_message(
+ r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git" {:tag (fn [_] "v1")})"#,
+ );
+ assert!(
+ msg.contains("missing field") && msg.contains("secret"),
+ "expected missing-secret error, got: {msg}"
+ );
+ }
+
+ #[test]
+ fn mirror_requires_tag() {
+ let msg = invalid_mirror_message(
+ r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git" {:secret :a})"#,
+ );
+ assert!(
+ msg.contains(":tag is required"),
+ "expected missing-tag error, got: {msg}"
+ );
+ }
+
+ #[test]
+ fn mirror_tag_must_be_function() {
+ let msg = invalid_mirror_message(
+ r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git" {:secret :a :tag "v1"})"#,
+ );
+ assert!(
+ msg.contains("must be a function"),
+ "expected tag-shape error, got: {msg}"
+ );
+ }
+
+ /// Compile a mirror source and return the registered Rust
+ /// run-fn ready to be invoked with a runtime that has
+ /// `:quire/push` populated.
+ fn mirror_run_fn(
+ source: &str,
+ secrets: HashMap<String, SecretString>,
+ meta: &super::super::run::RunMeta,
+ git_dir: &std::path::Path,
+ ) -> (Rc<Runtime>, super::super::pipeline::RustRunFn) {
+ let pipeline = super::super::pipeline::compile(source, "ci.fnl")
+ .expect("compile should succeed");
+ let run_fn = match pipeline
+ .jobs()
+ .iter()
+ .find(|j| j.id == "quire/mirror")
+ .expect("mirror job should exist")
+ .run_fn
+ .clone()
+ {
+ RunFn::Rust(f) => f,
+ RunFn::Lua(_) => panic!("mirror should register a RunFn::Rust"),
+ };
+ let runtime = Rc::new(Runtime::new(pipeline, secrets, meta, git_dir));
+ let _ = RuntimeHandle(runtime.clone())
+ .into_lua(runtime.lua())
+ .expect("install runtime");
+ (runtime, run_fn)
+ }
+
+ #[test]
+ fn mirror_executes_tag_callback_and_pushes() {
+ let (_dir, bare, sha) = bare_repo();
+ let pushed_at: jiff::Timestamp = "2026-05-01T12:00:00Z".parse().unwrap();
+ let meta = super::super::run::RunMeta {
+ sha: sha.clone(),
+ r#ref: "refs/heads/main".to_string(),
+ pushed_at,
+ };
+
+ let mut secrets = HashMap::new();
+ secrets.insert(
+ "github_token".to_string(),
+ SecretString::from_plain("fake_token"),
+ );
+
+ let source = r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git"
+ {:secret :github_token
+ :tag (fn [push] (.. "release-" (string.sub push.sha 1 8)))})"#;
+ let (runtime, run_fn) = mirror_run_fn(source, secrets, &meta, &bare);
+
+ runtime.enter_job("quire/mirror");
+ run_fn(&runtime).expect("mirror should succeed");
+ runtime.leave_job();
+
+ // Tag was created in the bare repo with the callback's name.
+ let expected_tag = format!("release-{}", &sha[..8]);
+ let tag_output = std::process::Command::new("git")
+ .args(["tag", "-l"])
+ .current_dir(&bare)
+ .output()
+ .expect("git tag -l");
+ let tags = String::from_utf8(tag_output.stdout).expect("utf8");
+ assert!(
+ tags.contains(&expected_tag),
+ "tag should exist in bare repo: {tags}"
+ );
+
+ // Outputs were recorded for the tag step and the push step
+ // (push to a fake URL fails non-zero, not via Err).
+ let outputs = runtime.take_outputs();
+ let recorded = outputs
+ .get("quire/mirror")
+ .expect("mirror outputs recorded");
+ assert_eq!(recorded.len(), 2, "expected tag + push outputs");
+ let push = recorded.last().unwrap();
+ assert_ne!(push.exit, 0, "push to fake URL should fail");
+ }
+
+ #[test]
+ fn mirror_pushes_listed_refs_when_refs_set() {
+ let (_dir, bare, sha) = bare_repo();
+ let pushed_at: jiff::Timestamp = "2026-05-01T12:00:00Z".parse().unwrap();
+ let meta = super::super::run::RunMeta {
+ sha,
+ r#ref: "refs/heads/feature".to_string(),
+ pushed_at,
+ };
+
+ let mut secrets = HashMap::new();
+ secrets.insert(
+ "github_token".to_string(),
+ SecretString::from_plain("fake_token"),
+ );
+
+ // :refs is set explicitly. Even though the trigger ref is
+ // `refs/heads/feature`, the mirror should push the listed
+ // refs verbatim.
+ let source = r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git"
+ {:secret :github_token
+ :tag (fn [_] "v1")
+ :refs ["refs/heads/main" "refs/heads/release"]})"#;
+ let (runtime, run_fn) = mirror_run_fn(source, secrets, &meta, &bare);
+
+ runtime.enter_job("quire/mirror");
+ run_fn(&runtime).expect("mirror should succeed");
+ runtime.leave_job();
+
+ let outputs = runtime.take_outputs();
+ let recorded = outputs.get("quire/mirror").expect("recorded");
+ // Tag step records first; push step second.
+ let push = recorded.last().expect("push output");
+ let cmd = &push.cmd;
+ assert!(
+ cmd.contains("refs/heads/main") && cmd.contains("refs/heads/release"),
+ "push cmd should list configured refs, got: {cmd}"
+ );
+ assert!(
+ !cmd.contains("refs/heads/feature"),
+ "push cmd should not include the trigger ref when :refs is set, got: {cmd}"
+ );
+ }
+
+ #[test]
+ fn mirror_errors_for_unknown_secret_at_runtime() {
+ let (_dir, bare, sha) = bare_repo();
+ let pushed_at: jiff::Timestamp = "2026-05-01T12:00:00Z".parse().unwrap();
+ let meta = super::super::run::RunMeta {
+ sha,
+ r#ref: "refs/heads/main".to_string(),
+ pushed_at,
+ };
+
+ let source = r#"(local ci (require :quire.ci))
+(ci.mirror "https://github.com/example/repo.git"
+ {:secret :missing :tag (fn [_] "v1")})"#;
+ let (runtime, run_fn) = mirror_run_fn(source, HashMap::new(), &meta, &bare);
+
+ runtime.enter_job("quire/mirror");
+ let err = run_fn(&runtime).expect_err("should fail for missing secret");
+ runtime.leave_job();
+
+ assert!(
+ matches!(err, Error::UnknownSecret(ref name) if name == "missing"),
+ "expected UnknownSecret(\"missing\"), got: {err:?}"
+ );
+ }
}
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index b1b9c48..bad931e 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -40,6 +40,19 @@ pub enum DefinitionError {
#[label("duplicate image declaration")]
span: SourceSpan,
},
+
+ #[error("Pipeline mirror declared more than once.")]
+ DuplicateMirror {
+ #[label("duplicate mirror declaration")]
+ span: SourceSpan,
+ },
+
+ #[error("ci.mirror: {message}")]
+ InvalidMirrorCall {
+ message: String,
+ #[label("here")]
+ span: SourceSpan,
+ },
}
/// A post-graph structural error found after all jobs have been
@@ -128,13 +141,19 @@ impl std::fmt::Debug for RunFn {
}
impl Job {
- /// Build a `Job` from the raw `(ci.job …)` arguments, applying the
- /// per-job validation rules. `line` is the 1-indexed source line of
- /// the call site; `source` is the full Fennel source string used to
- /// compute the diagnostic span.
+ /// Build a `Job`, applying the rules that apply to every job
+ /// regardless of how it was registered. `line` is the 1-indexed
+ /// source line of the call site; `source` is the full Fennel
+ /// source string used to compute the diagnostic span.
+ ///
+ /// The `quire/`-namespace check is the caller's responsibility —
+ /// user-facing `(ci.job …)` calls must reject slashes (see
+ /// `register_job`), but internal helpers (e.g. `register_mirror`)
+ /// legitimately register jobs at `quire/<name>` and skip that
+ /// rule.
///
/// Visible to the sibling `lua` module which constructs jobs from
- /// the registration callback.
+ /// the registration callbacks.
pub(super) fn new(
id: String,
inputs: Vec<String>,
@@ -144,10 +163,6 @@ impl Job {
) -> std::result::Result<Self, DefinitionError> {
let span = span_for_line(source, line);
- if id.contains('/') {
- return Err(DefinitionError::ReservedSlash { job_id: id, span });
- }
-
if inputs.is_empty() {
return Err(DefinitionError::EmptyInputs { job_id: id, span });
}
diff --git a/src/ci/run.rs b/src/ci/run.rs
index ca7caf3..87fb0c5 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -295,13 +295,13 @@ impl Run {
.clone();
runtime.enter_job(job_id);
- let result: Result<()> = match run_fn {
- RunFn::Lua(f) => f
- .call::<mlua::Value>(rt_value.clone())
- .map(|_| ())
- .map_err(|e| Error::Lua(Box::new(e))),
+ let result: Result<()> = (|| match run_fn {
+ RunFn::Lua(f) => {
+ let _: mlua::Value = f.call(rt_value.clone())?;
+ Ok(())
+ }
RunFn::Rust(f) => f(&runtime),
- };
+ })();
runtime.leave_job();
if let Err(e) = result {
diff --git a/src/error.rs b/src/error.rs
index 741ef14..5533a63 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -97,6 +97,12 @@ impl From<PipelineError> for Error {
}
}
+impl From<mlua::Error> for Error {
+ fn from(err: mlua::Error) -> Self {
+ Error::Lua(Box::new(err))
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;