Delegate ci.mirror shim to stdlib.mirror via Lua run-fn
Replace the Rust-implemented MirrorJob (struct + execute + parse) with
a thin register function that builds a Lua run-fn calling
stdlib.mirror. The git plumbing (tag + push) now lives entirely in
stdlib.fnl; the shim handles secret resolution, tag callback
invocation, and ref gating.

Behavioral change: push failures now raise instead of being silently
recorded, since stdlib.mirror errors on non-zero git exit. Tests
updated accordingly — runtime tests use real target repos where the
push must succeed, and the unknown-secret test checks the error
message rather than the RuntimeError variant.

Assisted-by: GLM-5.1 via pi
change vspzmkqxwqukwsoquynxpqqlzwzpspow
commit 87c8a0246ef916fdcd944a8aed9612bb48a27530
author Alpha Chen <alpha@kejadlen.dev>
date
parent yoqznqsw
diff --git a/quire-core/src/ci/mirror.rs b/quire-core/src/ci/mirror.rs
index 8d3da78..08b1582 100644
--- a/quire-core/src/ci/mirror.rs
+++ b/quire-core/src/ci/mirror.rs
@@ -1,241 +1,163 @@
 //! `(ci.mirror url opts)`: registers the singleton `quire/mirror`
-//! job, whose Rust run-fn tags the pushed commit and `git push`es
-//! the configured refs (or the trigger ref) plus the tag.
+//! job, whose Lua run-fn delegates to `(require :quire.stdlib).mirror`
+//! for the tag-and-push at execute time.
 //!
-//! `:refs` serves double duty: it gates whether the mirror job runs
-//! at all (trigger filter) and controls what gets pushed (push
-//! filter). If the trigger ref is not in `:refs`, the job is a
-//! no-op — no tag is created, no push is attempted. When `:refs` is
-//! empty (the default), the trigger ref is used for the push and
-//! the mirror always runs.
-//!
-//! Lives at the ci-feature layer rather than under `lua/` because
-//! mirror is a CI capability that happens to be exposed via the
-//! Lua DSL — most of its body is git plumbing, and it produces a
-//! `RunFn::Rust` rather than running through the Lua callback path.
+//! This shim handles the `ci.mirror`-specific concerns: resolving
+//! the `:secret` name into an auth header, invoking the `:tag`
+//! callback, and gating on `:refs`. The actual git plumbing lives
+//! in `stdlib.fnl`.
 
-use std::collections::HashMap;
 use std::rc::Rc;
 
 use mlua::{Lua, LuaSerdeExt};
 
 use super::pipeline::{self, DefinitionError, Job, RunFn};
 use super::registration::Registration;
-use super::runtime::{Cmd, Runtime, RuntimeError, RuntimeResult, ShOpts};
-
-/// Closure state for the `quire/mirror` job's run-fn: everything the
-/// tag-and-push needs at execute time, captured once at registration.
-pub struct MirrorJob {
-    url: String,
-    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,
-}
 
-impl MirrorJob {
-    /// Run the tag-and-push against the bare git dir from the runtime's
-    /// `quire/push` data. Side effects only — outputs are recorded
-    /// against the calling job via the 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(&self, rt: &Runtime) -> RuntimeResult<()> {
-        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")?;
-
-        // Gate: if :refs is set, only run when the trigger ref matches.
-        if !self.refs.is_empty() && !self.refs.contains(&pushed_ref) {
-            tracing::info!(
-                ref_name = %pushed_ref,
-                "skipping mirror — trigger ref not in :refs"
-            );
+/// Body of `(ci.mirror url opts)`. Validates opts and registers an
+/// internal job at `quire/mirror` whose Lua run-fn delegates to
+/// `stdlib.mirror` at execute time. Singleton-ness is enforced by
+/// generic id uniqueness in `Registration::add_job`.
+pub fn register(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);
+    let span = || pipeline::span_for_line(&r.source, line);
+    let invalid = |msg: String, s: _| DefinitionError::InvalidMirrorCall {
+        message: msg,
+        span: s,
+    };
+
+    // :tag — required function.
+    let tag: mlua::Function = match opts.get::<mlua::Value>("tag")? {
+        mlua::Value::Function(f) => f,
+        mlua::Value::Nil => {
+            r.errors.borrow_mut().push(invalid(
+                ":tag is required (a function returning the tag name)".into(),
+                span(),
+            ));
             return Ok(());
-        };
-        let git_dir: String = push_table.get("git-dir")?;
-
-        let git_opts = ShOpts {
-            env: HashMap::from([("GIT_DIR".to_string(), git_dir)]),
-        };
-
-        // Tag step.
-        let tag_name: String = self.tag.call(push_table.clone())?;
-        let tag_result = rt.sh(
-            Cmd::Argv {
-                program: "git".to_string(),
-                args: vec!["tag".to_string(), tag_name.clone(), sha],
-            },
-            git_opts.clone(),
-        )?;
-        if tag_result.exit != 0 {
-            return Err(RuntimeError::Git(format!(
-                "git tag failed: {}",
-                tag_result.stderr.trim()
-            )));
         }
-
-        // Push the configured refs (or the trigger ref, if none) plus the tag.
-        let auth_header = rt.secret(&self.secret)?;
-        let mut push_args = vec![
-            "-c".to_string(),
-            format!("http.extraHeader={auth_header}"),
-            "push".to_string(),
-            "--porcelain".to_string(),
-            self.url.clone(),
-        ];
-        if self.refs.is_empty() {
-            push_args.push(pushed_ref);
-        } else {
-            push_args.extend(self.refs.iter().cloned());
+        other => {
+            r.errors.borrow_mut().push(invalid(
+                format!(":tag must be a function, got {}", other.type_name()),
+                span(),
+            ));
+            return Ok(());
         }
-        push_args.push(format!("refs/tags/{tag_name}"));
-        rt.sh(
-            Cmd::Argv {
-                program: "git".to_string(),
-                args: push_args,
-            },
-            git_opts,
-        )?;
-
-        Ok(())
-    }
-
-    /// Parse `(ci.mirror url opts)` into a `MirrorJob` and the
-    /// `:after` list. `:after` only affects sequencing (extra inputs
-    /// on the registered job), so it stays out of the closure state.
-    ///
-    /// `: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(lua: &Lua, url: String, opts: mlua::Table) -> mlua::Result<(Self, Vec<String>)> {
-        #[derive(serde::Deserialize)]
-        #[serde(deny_unknown_fields)]
-        struct Fields {
-            secret: String,
-            #[serde(default)]
-            refs: Vec<String>,
-            #[serde(default)]
-            after: Vec<String>,
+    };
+
+    // :secret — required string.
+    let secret: String = match opts.get::<mlua::Value>("secret")? {
+        mlua::Value::String(s) => s.to_str()?.to_string(),
+        mlua::Value::Nil => {
+            r.errors
+                .borrow_mut()
+                .push(invalid("missing field `secret`".into(), span()));
+            return Ok(());
         }
-
-        // 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)?;
-            }
+        other => {
+            r.errors.borrow_mut().push(invalid(
+                format!(":secret must be a string, got {}", other.type_name()),
+                span(),
+            ));
+            return Ok(());
+        }
+    };
+
+    // :refs, :after — optional string lists.
+    let refs: Vec<String> = opts.get::<Option<Vec<String>>>("refs")?.unwrap_or_default();
+    let after: Vec<String> = opts
+        .get::<Option<Vec<String>>>("after")?
+        .unwrap_or_default();
+
+    // Reject unknown keys.
+    for pair in opts.pairs::<String, mlua::Value>() {
+        let (k, _) = pair?;
+        if !matches!(k.as_str(), "tag" | "secret" | "refs" | "after") {
+            r.errors
+                .borrow_mut()
+                .push(invalid(format!("unknown field `{k}`"), span()));
+            return Ok(());
         }
-
-        let fields: Fields = lua.from_value(mlua::Value::Table(stripped))?;
-
-        Ok((
-            Self {
-                url,
-                secret: fields.secret,
-                refs: fields.refs,
-                tag,
-            },
-            fields.after,
-        ))
     }
 
-    /// Body of `(ci.mirror url opts)`. Parses opts and registers an
-    /// internal job at `quire/mirror` whose run-fn performs the
-    /// tag-and-push at execute time. Singleton-ness is enforced by
-    /// generic id uniqueness in `Registration::add_job` — a second
-    /// `(ci.mirror …)` collides on the `quire/mirror` id.
-    pub fn register(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);
-
-        let (job, after) = match Self::parse(lua, url, opts) {
-            Ok(parsed) => parsed,
-            Err(e) => {
-                let span = pipeline::span_for_line(&r.source, line);
-                r.errors
-                    .borrow_mut()
-                    .push(DefinitionError::InvalidMirrorCall {
-                        message: e.to_string(),
-                        span,
-                    });
-                return Ok(());
-            }
-        };
+    // Build the Lua run-fn. Closes over registration-time values;
+    // at execute time the ambient runtime provides push data and
+    // secret resolution.
+    let url = Rc::new(url);
+    let secret = Rc::new(secret);
+    let refs = Rc::new(refs);
+
+    let run_fn = lua.create_function(move |lua, ()| {
+        let mirror: mlua::Function = lua
+            .globals()
+            .get::<mlua::Table>("package")?
+            .get::<mlua::Table>("loaded")?
+            .get::<mlua::Table>("quire.stdlib")?
+            .get("mirror")?;
+
+        let runtime: mlua::Table = lua.globals().get("runtime")?;
+        let push: mlua::Table = runtime.get::<mlua::Function>("jobs")?.call("quire/push")?;
+        let pushed_ref: String = push.get("ref")?;
+
+        // Gate: skip if :refs is set and trigger ref doesn't match.
+        if !refs.is_empty() && !refs.iter().any(|r| r == &pushed_ref) {
+            return Ok(mlua::Value::Nil);
+        }
 
-        let run_fn = RunFn::Rust(Rc::new(move |rt: &Runtime| job.execute(rt)));
+        let tag_name: String = tag.call(push.clone())?;
+        let auth_header: String = runtime
+            .get::<mlua::Function>("secret")?
+            .call(secret.as_str())?;
 
-        // 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(after);
+        let push_refs = if refs.is_empty() {
+            vec![pushed_ref]
+        } else {
+            refs.to_vec()
+        };
 
-        match Job::new("quire/mirror".to_string(), inputs, run_fn, line, &r.source) {
-            Ok(job) => r.add_job(job, line),
-            Err(e) => r.errors.borrow_mut().push(e),
-        }
-        Ok(())
+        let mopts = lua.create_table()?;
+        mopts.set("url", url.as_str())?;
+        mopts.set("auth-header", auth_header)?;
+        mopts.set("sha", push.get::<String>("sha")?)?;
+        mopts.set("tag", tag_name)?;
+        mopts.set("git-dir", push.get::<String>("git-dir")?)?;
+        mopts.set("refs", lua.to_value(&push_refs)?)?;
+
+        mirror.call(mopts)
+    })?;
+
+    let mut inputs = vec!["quire/push".to_string()];
+    inputs.extend(after);
+    match Job::new(
+        "quire/mirror".into(),
+        inputs,
+        RunFn::Lua(run_fn),
+        line,
+        &r.source,
+    ) {
+        Ok(job) => r.add_job(job, line),
+        Err(e) => r.errors.borrow_mut().push(e),
     }
+    Ok(())
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
-    use crate::ci::pipeline::{Diagnostic, RustRunFn, compile};
+    use std::collections::HashMap;
+
+    use crate::ci::pipeline::{Diagnostic, compile};
     use crate::ci::run::RunMeta;
     use crate::ci::runtime::RuntimeHandle;
-    use crate::secret::{Error as SecretError, SecretString};
+    use crate::secret::SecretString;
 
     /// Set up a bare git repo with one commit. Returns the tempdir,
     /// the bare repo path, and the head SHA.
@@ -294,8 +216,59 @@ mod tests {
         (dir, bare, sha)
     }
 
-    /// Pull the mirror job out of a compiled pipeline. Panics if no
-    /// `quire/mirror` job is present.
+    /// Build a source bare repo with one commit and an empty target
+    /// bare repo. Returns (tempdir, source bare, target bare, sha).
+    fn bare_repo_with_target() -> (
+        tempfile::TempDir,
+        std::path::PathBuf,
+        std::path::PathBuf,
+        String,
+    ) {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let work = dir.path().join("work");
+        let bare = dir.path().join("repo.git");
+        let target = dir.path().join("target.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 git = |args: &[&str], cwd: &std::path::Path| {
+            let out = std::process::Command::new("git")
+                .args(args)
+                .current_dir(cwd)
+                .envs(env_vars)
+                .output()
+                .expect("git");
+            assert!(out.status.success(), "git {:?} failed", args);
+            out
+        };
+        git(&["init", "-b", "main"], &work);
+        git(&["commit", "--allow-empty", "-m", "initial"], &work);
+        let sha = String::from_utf8(git(&["rev-parse", "HEAD"], &work).stdout)
+            .expect("utf8")
+            .trim()
+            .to_string();
+        git(
+            &[
+                "clone",
+                "--bare",
+                work.to_str().unwrap(),
+                bare.to_str().unwrap(),
+            ],
+            dir.path(),
+        );
+        git(&["init", "--bare", target.to_str().unwrap()], dir.path());
+
+        (dir, bare, target, sha)
+    }
+
+    /// Pull the mirror job's inputs from a compiled pipeline.
     fn mirror_job_inputs(source: &str) -> Vec<String> {
         let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         pipeline
@@ -417,15 +390,16 @@ mod tests {
         );
     }
 
-    /// Compile a mirror source and return the registered Rust
-    /// run-fn ready to be invoked with a runtime that has
-    /// `:quire/push` populated.
+    /// Compile a mirror source and return the runtime and the mirror
+    /// job's Lua run-fn ready to be invoked with the ambient runtime
+    /// installed.
     fn mirror_run_fn(
         source: &str,
         secrets: HashMap<String, SecretString>,
         meta: &RunMeta,
         git_dir: &std::path::Path,
-    ) -> (Rc<Runtime>, RustRunFn) {
+    ) -> (Rc<crate::ci::runtime::Runtime>, mlua::Function) {
+        use crate::ci::runtime::Runtime;
         let pipeline = compile(source, "ci.fnl").expect("compile should succeed");
         let run_fn = match pipeline
             .jobs()
@@ -435,8 +409,8 @@ mod tests {
             .run_fn
             .clone()
         {
-            RunFn::Rust(f) => f,
-            RunFn::Lua(_) => panic!("mirror should register a RunFn::Rust"),
+            RunFn::Lua(f) => f,
+            RunFn::Rust(_) => panic!("mirror should register a RunFn::Lua"),
         };
         let log_dir = tempfile::tempdir().expect("tempdir for mirror logs").keep();
         let runtime = Rc::new(Runtime::new(
@@ -455,7 +429,7 @@ mod tests {
 
     #[test]
     fn mirror_executes_tag_callback_and_pushes() {
-        let (_dir, bare, sha) = bare_repo();
+        let (_dir, bare, target, sha) = bare_repo_with_target();
         let pushed_at: jiff::Timestamp = "2026-05-01T12:00:00Z".parse().unwrap();
         let meta = RunMeta {
             sha: sha.clone(),
@@ -464,45 +438,55 @@ mod tests {
         };
 
         let mut secrets = HashMap::new();
-        secrets.insert("github_token".to_string(), SecretString::from("fake_token"));
+        secrets.insert(
+            "github_token".to_string(),
+            SecretString::from("Authorization: Bearer test-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);
+        let source = format!(
+            r#"(local ci (require :quire.ci))
+(ci.mirror "{url}"
+  {{:secret :github_token
+   :tag (fn [push] (.. "release-" (string.sub push.sha 1 8)))}})"#,
+            url = format!("file://{}", target.display()),
+        );
+        let (runtime, run_fn) = mirror_run_fn(&source, secrets, &meta, &bare);
 
         runtime.enter_job("quire/mirror");
-        run_fn(&runtime).expect("mirror should succeed");
+        let _: mlua::Value = run_fn.call(()).expect("mirror should succeed");
         runtime.leave_job();
 
-        // Tag was created in the bare repo with the callback's name.
+        // Tag landed in the target repo via the push.
         let expected_tag = format!("release-{}", &sha[..8]);
         let tag_output = std::process::Command::new("git")
             .args(["tag", "-l"])
-            .current_dir(&bare)
+            .current_dir(&target)
             .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}"
+            "tag should exist in target repo: {tags}"
         );
 
-        // Outputs were recorded for the tag step and the push step
-        // (push to a fake URL fails non-zero, not via Err).
+        // Tag and push outputs were recorded.
         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_trigger_ref_matches() {
-        let (_dir, bare, sha) = bare_repo();
+        let (_dir, bare, target, sha) = bare_repo_with_target();
+        // Create a release branch so git can push it.
+        std::process::Command::new("git")
+            .args(["branch", "release"])
+            .current_dir(&bare)
+            .output()
+            .expect("git branch release");
+
         let pushed_at: jiff::Timestamp = "2026-05-01T12:00:00Z".parse().unwrap();
         let meta = RunMeta {
             sha,
@@ -511,19 +495,25 @@ mod tests {
         };
 
         let mut secrets = HashMap::new();
-        secrets.insert("github_token".to_string(), SecretString::from("fake_token"));
+        secrets.insert(
+            "github_token".to_string(),
+            SecretString::from("Authorization: Bearer test-token"),
+        );
 
         // :refs is set and the trigger ref matches, so 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
+        let source = format!(
+            r#"(local ci (require :quire.ci))
+(ci.mirror "{url}"
+  {{:secret :github_token
    :tag (fn [_] "v1")
-   :refs ["refs/heads/main" "refs/heads/release"]})"#;
-        let (runtime, run_fn) = mirror_run_fn(source, secrets, &meta, &bare);
+   :refs ["refs/heads/main" "refs/heads/release"]}})"#,
+            url = format!("file://{}", target.display()),
+        );
+        let (runtime, run_fn) = mirror_run_fn(&source, secrets, &meta, &bare);
 
         runtime.enter_job("quire/mirror");
-        run_fn(&runtime).expect("mirror should succeed");
+        let _: mlua::Value = run_fn.call(()).expect("mirror should succeed");
         runtime.leave_job();
 
         let outputs = runtime.take_outputs();
@@ -548,7 +538,10 @@ mod tests {
         };
 
         let mut secrets = HashMap::new();
-        secrets.insert("github_token".to_string(), SecretString::from("fake_token"));
+        secrets.insert(
+            "github_token".to_string(),
+            SecretString::from("Authorization: Bearer test-token"),
+        );
 
         // Trigger ref is feature, but :refs only lists main — mirror
         // should be a no-op.
@@ -560,7 +553,7 @@ mod tests {
         let (runtime, run_fn) = mirror_run_fn(source, secrets, &meta, &bare);
 
         runtime.enter_job("quire/mirror");
-        run_fn(&runtime).expect("mirror should succeed (no-op)");
+        let _: mlua::Value = run_fn.call(()).expect("mirror should succeed (no-op)");
         runtime.leave_job();
 
         let outputs = runtime.take_outputs();
@@ -596,12 +589,13 @@ mod tests {
         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");
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
         runtime.leave_job();
 
+        let msg = err.to_string();
         assert!(
-            matches!(err, RuntimeError::Secret(SecretError::UnknownSecret(ref name)) if name == "missing"),
-            "expected UnknownSecret(\"missing\"), got: {err:?}"
+            msg.contains("missing"),
+            "expected UnknownSecret(\"missing\"), got: {msg}"
         );
     }
 }
diff --git a/quire-core/src/ci/registration.rs b/quire-core/src/ci/registration.rs
index f0d7a14..3832410 100644
--- a/quire-core/src/ci/registration.rs
+++ b/quire-core/src/ci/registration.rs
@@ -110,7 +110,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(mirror::MirrorJob::register)?)?;
+        table.set("mirror", lua.create_function(mirror::register)?)?;
         table.into_lua(lua)
     }
 }