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
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!(