Promote secret and sh to Runtime methods
MirrorJob::execute duplicated the secret-resolution and
output-recording logic the Lua callbacks already had. Lifting both onto
Runtime lets Rust run-fns and Lua adapters share one implementation.

Assisted-by: Claude Opus 4.7 via Claude Code
change qusqzmxwlvunvtnplownmuomutlytzxw
commit db0a4c5f789c364b990a79d77e7ceca3ff5dde33
author Alpha Chen <alpha@kejadlen.dev>
date
parent lyntzqnq
diff --git a/src/ci/mirror.rs b/src/ci/mirror.rs
index dc94e0b..578fb24 100644
--- a/src/ci/mirror.rs
+++ b/src/ci/mirror.rs
@@ -14,7 +14,7 @@ use mlua::{Lua, LuaSerdeExt};
 
 use super::pipeline::{self, DefinitionError, Job, RunFn};
 use super::registration::Registration;
-use super::runtime::{Cmd, Runtime, ShOpts, ShOutput};
+use super::runtime::{Cmd, Runtime, ShOpts};
 use crate::Result;
 use crate::error::Error;
 
@@ -62,36 +62,35 @@ impl MirrorJob {
         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(&self.secret)
-            .ok_or_else(|| Error::UnknownSecret(self.secret.clone()))?
-            .reveal()?
-            .to_string();
+        let secret = rt.secret(&self.secret)?;
 
         let git_opts = ShOpts {
-            env: HashMap::from([("GIT_DIR".to_string(), git_dir.clone())]),
+            env: HashMap::from([("GIT_DIR".to_string(), git_dir)]),
             cwd: None,
         };
 
         // Tag step.
         let tag_name: String = self.tag.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())));
+        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(Error::Git(format!(
+                "git tag failed: {}",
+                tag_result.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.
+        //
+        // Run via `Cmd::run` rather than `rt.sh` — we don't want the
+        // encoded token landing in recorded outputs.
         let token_pair = format!("x-access-token:{secret}");
         let encoded_output =
             Cmd::Shell("printf '%s' \"$T\" | base64 --wrap=0".to_string()).run(ShOpts {
@@ -114,12 +113,13 @@ impl MirrorJob {
             push_args.extend(self.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);
+        rt.sh(
+            Cmd::Argv {
+                program: "git".to_string(),
+                args: push_args,
+            },
+            git_opts,
+        )?;
 
         Ok(())
     }
@@ -233,15 +233,6 @@ pub(super) fn register_mirror(lua: &Lua, (url, opts): (String, mlua::Table)) ->
     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::*;
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index 9f97687..263ae2c 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -144,6 +144,31 @@ impl Runtime {
     pub(super) fn take_outputs(&self) -> HashMap<String, Vec<ShOutput>> {
         std::mem::take(&mut *self.outputs.borrow_mut())
     }
+
+    /// Resolve a declared secret by name. Errors if the name isn't
+    /// declared or the secret's source can't be read.
+    pub(super) fn secret(&self, name: &str) -> crate::Result<String> {
+        let secret = self
+            .secrets
+            .get(name)
+            .ok_or_else(|| crate::Error::UnknownSecret(name.to_string()))?;
+        Ok(secret.reveal()?.to_string())
+    }
+
+    /// Run `cmd` with `opts` and record its output against the
+    /// current job (if one is active). Non-zero exits come back in
+    /// `:exit`, not as `Err`.
+    pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> crate::Result<ShOutput> {
+        let output = cmd.run(opts)?;
+        if let Some(job) = self.current_job.borrow().as_ref() {
+            self.outputs
+                .borrow_mut()
+                .entry(job.clone())
+                .or_default()
+                .push(output.clone());
+        }
+        Ok(output)
+    }
 }
 
 #[cfg(test)]
@@ -166,73 +191,75 @@ impl Runtime {
 pub(super) struct RuntimeHandle(pub Rc<Runtime>);
 
 impl IntoLua for RuntimeHandle {
+    // Errors raised by the closures below cross the mlua boundary via
+    // `Error::external`, which erases them to
+    // `Box<dyn Error + Send + Sync>`. The `std::error::Error` source
+    // chain is preserved, but miette `Diagnostic` metadata (codes,
+    // labels, source spans) does not survive the round trip — the
+    // resulting `mlua::Error` becomes the `#[source]` of
+    // `Error::JobFailed` at the executor, which only renders the chain
+    // as plain `Display`. Don't reach for richer error types here
+    // expecting them to render: rephrase the Display string to carry
+    // what the user needs to see.
     fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
         lua.set_app_data(self.0);
         let table = lua.create_table()?;
-        table.set("sh", lua.create_function(run_sh)?)?;
-        table.set("secret", lua.create_function(lookup_secret)?)?;
-        table.set("jobs", lua.create_function(lookup_input)?)?;
-        table.into_lua(lua)
-    }
-}
 
-/// Body of `(jobs name)`. Returns the outputs the calling job's
-/// view has for `name` as a Lua value. Reachable names without
-/// recorded outputs come back as `nil`. Errors if `name` is outside
-/// the calling job's view, if the calling job tries to read its own
-/// outputs, or if the runtime isn't installed.
-fn lookup_input(lua: &Lua, name: String) -> mlua::Result<mlua::Value> {
-    let rt = lua
-        .app_data_ref::<Rc<Runtime>>()
-        .ok_or_else(|| mlua::Error::external("runtime not installed on Lua VM"))?;
-    let calling = rt.current_job.borrow();
-    let calling = calling
-        .as_ref()
-        .ok_or_else(|| mlua::Error::external("(jobs ...) called outside a job's run-fn"))?;
-    // Runtime::new builds a view for every job and enter_job is the only
-    // setter for current_job, so a missing view is a programming error,
-    // not a user-reachable condition.
-    let view = rt
-        .inputs
-        .get(calling)
-        .unwrap_or_else(|| unreachable!("no inputs view for calling job '{calling}'"));
-    match view.get(&name) {
-        Some(Some(value)) => Ok(value.clone()),
-        Some(None) => Ok(mlua::Value::Nil),
-        None if name == *calling => Err(mlua::Error::external(format!(
-            "Job '{calling}' cannot read its own outputs"
-        ))),
-        None => Err(mlua::Error::external(format!(
-            "Job '{calling}' cannot read outputs from '{name}' — not in transitive inputs"
-        ))),
-    }
-}
+        table.set(
+            "sh",
+            lua.create_function(|lua, (cmd, opts): (Cmd, Option<ShOpts>)| {
+                let opts = opts.unwrap_or_default();
+                let output = match lua.app_data_ref::<Rc<Runtime>>() {
+                    Some(rt) => rt.sh(cmd, opts).map_err(mlua::Error::external)?,
+                    None => cmd.run(opts).map_err(mlua::Error::external)?,
+                };
+                lua.to_value(&output)
+            })?,
+        )?;
+
+        table.set(
+            "secret",
+            lua.create_function(|lua, name: String| {
+                let rt = lua
+                    .app_data_ref::<Rc<Runtime>>()
+                    .ok_or_else(|| mlua::Error::external("runtime not installed on Lua VM"))?;
+                rt.secret(&name).map_err(mlua::Error::external)
+            })?,
+        )?;
+
+        table.set(
+            "jobs",
+            lua.create_function(|lua, name: String| {
+                let rt = lua
+                    .app_data_ref::<Rc<Runtime>>()
+                    .ok_or_else(|| mlua::Error::external("runtime not installed on Lua VM"))?;
+                let calling = rt.current_job.borrow();
+                let calling = calling.as_ref().ok_or_else(|| {
+                    mlua::Error::external("(jobs ...) called outside a job's run-fn")
+                })?;
+                // Runtime::new builds a view for every job and
+                // enter_job is the only setter for current_job, so a
+                // missing view is a programming error, not a
+                // user-reachable condition.
+                let view = rt
+                    .inputs
+                    .get(calling)
+                    .unwrap_or_else(|| unreachable!("no inputs view for calling job '{calling}'"));
+                match view.get(&name) {
+                    Some(Some(value)) => Ok(value.clone()),
+                    Some(None) => Ok(mlua::Value::Nil),
+                    None if name == *calling => Err(mlua::Error::external(format!(
+                        "Job '{calling}' cannot read its own outputs"
+                    ))),
+                    None => Err(mlua::Error::external(format!(
+                        "Job '{calling}' cannot read outputs from '{name}' — not in transitive inputs"
+                    ))),
+                }
+            })?,
+        )?;
 
-/// 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.
-//
-// Errors here cross the mlua boundary via `Error::external`, which
-// erases them to `Box<dyn Error + Send + Sync>`. The `std::error::Error`
-// source chain is preserved, but miette `Diagnostic` metadata
-// (codes, labels, source spans) does not survive the round trip —
-// the resulting `mlua::Error` becomes the `#[source]` of
-// `Error::JobFailed` at the executor, which only renders the chain
-// as plain `Display`. Don't reach for richer error types here
-// expecting them to render: rephrase the Display string to carry
-// what the user needs to see.
-fn lookup_secret(lua: &Lua, name: String) -> mlua::Result<String> {
-    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)))?;
-    secret
-        .reveal()
-        .map(|s| s.to_string())
-        .map_err(mlua::Error::external)
+        table.into_lua(lua)
+    }
 }
 
 /// The two valid shapes of `cmd` for `(sh cmd …)`. A bare string
@@ -380,28 +407,6 @@ pub struct ShOutput {
     pub cmd: String,
 }
 
-/// 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<Runtime>>()
-        && let Some(job) = rt.current_job.borrow().as_ref()
-    {
-        rt.outputs
-            .borrow_mut()
-            .entry(job.clone())
-            .or_default()
-            .push(output.clone());
-    }
-
-    lua.to_value(&output)
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;