Add quire.stdlib Fennel module with mirror helper
Mirror is a recipe over `sh` and `secret`, not a runtime primitive —
keeping it in Fennel keeps the runtime kernel small and lets future
helpers ship without Rust changes.

The auth header goes through `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_0` /
`GIT_CONFIG_VALUE_0` env rather than `-c http.extraHeader=…` in argv,
so it doesn't appear in `ps` or any argv-logging we add later.

Assisted-by: Claude Opus 4.7 via Claude Code
change yluxrrnlkvtvoxukwtquqtqsxwrmyzuw
commit ee4f3e8e1f13ab6688af8a7bf793a17385c3f68c
author Alpha Chen <alpha@kejadlen.dev>
date
parent sqpzkwnm
diff --git a/docs/CI-FENNEL.md b/docs/CI-FENNEL.md
index e9805fe..7ce54ea 100644
--- a/docs/CI-FENNEL.md
+++ b/docs/CI-FENNEL.md
@@ -231,6 +231,34 @@ Each of these blocks the Fennel function until it returns. Multi-`sh`-call paral
 
 The execute VM is sandboxed (no `io`/`os`/`debug`), so `runtime.sh` is the documented chokepoint for any host effect — `os.execute` and `io.open` are not available alternates. See CI.md for the full sandbox shape and the bwrap opt-in for the untrusted-code threat model.
 
+`runtime` is also reachable as a module: `(let [{: sh : secret} (require :quire.runtime)] …)`. Same table, same closures — useful for library code that wants its dependencies explicit.
+
+## Stdlib (`quire.stdlib`)
+
+Helpers that compose runtime primitives into common recipes. Embedded into the binary; available via `(require :quire.stdlib)` from any run-fn.
+
+The kernel (`sh`/`secret`/`jobs`) stays small. Higher-level operations like tag-and-push live in Fennel where they're easier to read and evolve.
+
+```
+(local {: mirror} (require :quire.stdlib))
+
+(ci.job :mirror [:quire/push :test]
+  (fn []
+    (let [push (runtime.jobs :quire/push)]
+      (mirror {:url     "https://github.com/example/repo.git"
+               :secret  :github_auth_header
+               :sha     push.sha
+               :tag     (.. "quire-" (string.sub push.sha 1 8))
+               :git-dir (. push :git-dir)
+               :refs    ["refs/heads/main"]}))))
+```
+
+Available helpers:
+
+* `(mirror opts)` — tag a commit and push it (plus optional refs) to a remote. `opts.url`, `opts.secret`, `opts.sha`, `opts.tag`, and `opts.git-dir` are required; `opts.refs` defaults to `[]`. The auth header (resolved via `runtime.secret`) is passed via `GIT_CONFIG_*` env vars rather than `-c http.extraHeader=…` in argv, so it doesn't appear in `ps` listings. Returns `{:tag :pushed_refs}`. Raises on missing required opts, unknown secrets, or non-zero git exits.
+
+`(ci.mirror …)` (the registration-time form) remains as a convenience wrapper that registers a singleton `quire/mirror` job. Use the stdlib form when you want to mirror conditionally or as part of a larger run-fn.
+
 ## A worked example
 
 ```
diff --git a/quire-core/src/ci/runtime.rs b/quire-core/src/ci/runtime.rs
index 0a3c7bd..96ddfbf 100644
--- a/quire-core/src/ci/runtime.rs
+++ b/quire-core/src/ci/runtime.rs
@@ -450,7 +450,13 @@ impl RuntimeHandle {
             )?,
         )?;
         rt.set_metatable(Some(mt))?;
-        lua.globals().set("runtime", rt)?;
+        lua.globals().set("runtime", rt.clone())?;
+        // Mirror into `package.loaded["quire.runtime"]` so library
+        // code can do `(let [{: sh} (require :quire.runtime)] …)`.
+        // Same table — destructures and ambient access stay in sync.
+        let package: mlua::Table = lua.globals().get("package")?;
+        let loaded: mlua::Table = package.get("loaded")?;
+        loaded.set("quire.runtime", rt)?;
         Ok(())
     }
 }
@@ -892,4 +898,140 @@ mod tests {
             "expected type error, got: {msg}"
         );
     }
+
+    // --- quire.stdlib mirror tests ---
+
+    /// Run `git` once with the standard test env. Asserts success and
+    /// returns stdout. Used by the mirror fixture below to set up the
+    /// source and target bare repos.
+    fn git(args: &[&str], cwd: &std::path::Path) -> String {
+        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 out = std::process::Command::new("git")
+            .args(args)
+            .current_dir(cwd)
+            .envs(env_vars)
+            .output()
+            .expect("git");
+        assert!(
+            out.status.success(),
+            "git {:?} failed: {}",
+            args,
+            String::from_utf8_lossy(&out.stderr)
+        );
+        String::from_utf8(out.stdout).expect("utf8")
+    }
+
+    /// Build a source bare repo with one commit and an empty target
+    /// bare repo in the same tempdir. Returns (tempdir, source bare,
+    /// target bare, head 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");
+        git(&["init", "-b", "main"], &work);
+        git(&["commit", "--allow-empty", "-m", "initial"], &work);
+        let sha = git(&["rev-parse", "HEAD"], &work).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)
+    }
+
+    #[test]
+    fn stdlib_mirror_tags_and_pushes() {
+        let (_dir, bare, target, sha) = bare_repo_with_target();
+
+        let mut secrets = HashMap::new();
+        secrets.insert(
+            "github_token".to_string(),
+            SecretString::from("Authorization: Bearer test-token"),
+        );
+
+        let source = format!(
+            r#"(local ci (require :quire.ci))
+(local {{: mirror}} (require :quire.stdlib))
+(ci.job :go [:quire/push]
+  (fn []
+    (mirror {{:url "{url}"
+             :secret :github_token
+             :sha "{sha}"
+             :tag "v1"
+             :git-dir "{git_dir}"}})))"#,
+            url = format!("file://{}", target.display()),
+            sha = sha,
+            git_dir = bare.display(),
+        );
+
+        let (_runtime, run_fn) = rt(&source, secrets);
+        let _: mlua::Value = run_fn.call(()).expect("mirror should succeed");
+
+        // Tag landed in the target repo, pointing at the head SHA.
+        let resolved = git(&["rev-parse", "refs/tags/v1"], &target);
+        assert_eq!(resolved.trim(), sha);
+    }
+
+    #[test]
+    fn stdlib_mirror_errors_on_missing_required_opt() {
+        let source = r#"(local ci (require :quire.ci))
+(local {: mirror} (require :quire.stdlib))
+(ci.job :go [:quire/push]
+  (fn []
+    (mirror {:secret :github_token :sha "x" :tag "v1" :git-dir "/tmp"})))"#;
+        let (_runtime, run_fn) = rt(source, HashMap::new());
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("missing required option :url"),
+            "expected missing-:url error, got: {msg}"
+        );
+    }
+
+    #[test]
+    fn stdlib_mirror_errors_on_unknown_secret() {
+        let (_dir, bare, target, sha) = bare_repo_with_target();
+        let source = format!(
+            r#"(local ci (require :quire.ci))
+(local {{: mirror}} (require :quire.stdlib))
+(ci.job :go [:quire/push]
+  (fn []
+    (mirror {{:url "{url}"
+             :secret :nope
+             :sha "{sha}"
+             :tag "v1"
+             :git-dir "{git_dir}"}})))"#,
+            url = format!("file://{}", target.display()),
+            sha = sha,
+            git_dir = bare.display(),
+        );
+        let (_runtime, run_fn) = rt(&source, HashMap::new());
+        let err = run_fn.call::<mlua::Value>(()).unwrap_err();
+        let msg = err.to_string();
+        assert!(
+            msg.contains("unknown secret") && msg.contains("nope"),
+            "expected unknown-secret error mentioning the name, got: {msg}"
+        );
+    }
 }
diff --git a/quire-core/src/ci/stdlib.fnl b/quire-core/src/ci/stdlib.fnl
new file mode 100644
index 0000000..c5d5c7d
--- /dev/null
+++ b/quire-core/src/ci/stdlib.fnl
@@ -0,0 +1,57 @@
+;; quire.stdlib — helpers callable from inside any run-fn via
+;; `(require :quire.stdlib)`. Each function pulls its runtime
+;; primitives from `(require :quire.runtime)` at call time so the
+;; binding always tracks the currently-installed runtime.
+
+(local M {})
+
+(fn missing [field]
+  (error (.. "quire.stdlib.mirror: missing required option :" field)))
+
+(fn trim [s]
+  (string.gsub s "%s+$" ""))
+
+;; (mirror opts)
+;;
+;; Tag a commit and push the tag (plus optional refs) to a remote.
+;;
+;; opts: {:url       — remote URL (required)
+;;        :secret    — secret name resolved via runtime.secret (required)
+;;        :sha       — commit to tag (required)
+;;        :tag       — tag name (required)
+;;        :git-dir   — bare git directory the run is scoped to (required)
+;;        :refs      — extra refs to push alongside the tag (optional, default [])}
+;;
+;; Returns {:tag :pushed_refs}. Raises on missing required opts,
+;; unknown secrets, or non-zero git exits.
+(fn M.mirror [opts]
+  (let [{: secret : sh} (require :quire.runtime)
+        url (or opts.url (missing :url))
+        secret-name (or opts.secret (missing :secret))
+        sha (or opts.sha (missing :sha))
+        tag (or opts.tag (missing :tag))
+        git-dir (or (. opts :git-dir) (missing :git-dir))
+        refs (or opts.refs [])
+        auth-header (secret secret-name)
+        ;; Pass http.extraHeader via GIT_CONFIG_* env (git 2.31+)
+        ;; instead of `-c http.extraHeader=…` in argv. Keeps the auth
+        ;; header out of `ps` and out of any argv logging we add
+        ;; later; runtime.sh's redact pass on stdout/stderr remains as
+        ;; defense in depth.
+        sh-opts {:env {:GIT_DIR git-dir
+                       :GIT_CONFIG_COUNT :1
+                       :GIT_CONFIG_KEY_0 :http.extraHeader
+                       :GIT_CONFIG_VALUE_0 auth-header}}
+        tag-result (sh [:git :tag tag sha] sh-opts)]
+    (when (not= 0 tag-result.exit)
+      (error (.. "git tag failed: " (trim tag-result.stderr))))
+    (let [push-args [:git :push :--porcelain url]]
+      (each [_ ref (ipairs refs)]
+        (table.insert push-args ref))
+      (table.insert push-args (.. :refs/tags/ tag))
+      (let [push-result (sh push-args sh-opts)]
+        (when (not= 0 push-result.exit)
+          (error (.. "git push failed: " (trim push-result.stderr))))
+        {: tag :pushed_refs refs}))))
+
+M
diff --git a/quire-core/src/fennel.rs b/quire-core/src/fennel.rs
index 45138a6..bc1bfa6 100644
--- a/quire-core/src/fennel.rs
+++ b/quire-core/src/fennel.rs
@@ -6,6 +6,12 @@ use thiserror::Error;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
+/// Embedded Fennel stdlib source — exposed as `(require :quire.stdlib)`
+/// to user pipelines. Shipping helpers here keeps the runtime kernel
+/// (`sh`/`secret`/`jobs`) small while letting common recipes live in
+/// Fennel where they're easier to evolve.
+const STDLIB_FNL: &str = include_str!("ci/stdlib.fnl");
+
 /// Error kinds from the Fennel loader.
 #[derive(Debug, Error, Diagnostic)]
 pub enum FennelError {
@@ -71,7 +77,32 @@ impl Fennel {
 
         lua.globals().set("fennel", fennel_module)?;
 
-        Ok(Self { lua })
+        // Stub `runtime` so the embedded stdlib (and user pipelines)
+        // compile under Fennel's strict-globals check. Mirror it into
+        // `package.loaded["quire.runtime"]` so library code can write
+        // `(require :quire.runtime)` instead of touching the global.
+        // Both slots are replaced at execution time by
+        // `RuntimeHandle::install`.
+        let stub: mlua::Table = lua.create_table()?;
+        lua.globals().set("runtime", stub.clone())?;
+        let package: mlua::Table = lua.globals().get("package")?;
+        let loaded: mlua::Table = package.get("loaded")?;
+        loaded.set("quire.runtime", stub)?;
+
+        let f = Self { lua };
+        f.preload_stdlib()?;
+        Ok(f)
+    }
+
+    /// Compile the embedded `quire.stdlib` and register it in
+    /// `package.loaded` so `(require :quire.stdlib)` returns the
+    /// module table without hitting the filesystem.
+    fn preload_stdlib(&self) -> Result<(), FennelError> {
+        let module = self.eval_raw(STDLIB_FNL, "quire.stdlib", |_| Ok(()))?;
+        let package: mlua::Table = self.lua.globals().get("package")?;
+        let loaded: mlua::Table = package.get("loaded")?;
+        loaded.set("quire.stdlib", module)?;
+        Ok(())
     }
 
     /// Borrow the underlying Lua VM. Useful for callers that need to
@@ -402,6 +433,35 @@ mod tests {
         assert_eq!(result.as_integer(), Some(42));
     }
 
+    #[test]
+    fn stdlib_module_preloaded_at_construction() {
+        let f = fennel();
+        let module: mlua::Table = f
+            .lua()
+            .load(r#"return require("quire.stdlib")"#)
+            .eval()
+            .expect("require quire.stdlib");
+        let _: mlua::Function = module.get("mirror").expect("mirror should be a function");
+    }
+
+    #[test]
+    fn quire_runtime_module_preloaded_to_stub() {
+        let f = fennel();
+        // Before install, `(require :quire.runtime)` returns the stub
+        // table — same reference as the `runtime` global.
+        let stub_via_require: mlua::Table = f
+            .lua()
+            .load(r#"return require("quire.runtime")"#)
+            .eval()
+            .expect("require quire.runtime");
+        let stub_via_global: mlua::Table =
+            f.lua().globals().get("runtime").expect("runtime global");
+        assert!(
+            stub_via_require.equals(&stub_via_global).unwrap(),
+            "require and global should resolve to the same stub"
+        );
+    }
+
     #[test]
     fn extract_line_col_parses_line_and_column() {
         assert_eq!(