Expose (ci:secret :name) to ci.fnl
Plumbs the global :secrets map through pipeline::load and binds it as
a method on the quire.ci module so jobs can resolve named secrets at
runtime. Lookup errors and file-read failures surface as Lua errors
inside the job's run function — the same path any other primitive
takes when it fails.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/src/bin/quire/commands/ci.rs b/src/bin/quire/commands/ci.rs
index 71d0d83..26f397f 100644
--- a/src/bin/quire/commands/ci.rs
+++ b/src/bin/quire/commands/ci.rs
@@ -13,7 +13,8 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
let commit = resolve_commit(maybe_sha)?;
let ci = Ci::new(repo_path);
- let Some(pipeline) = ci.load(&commit)? else {
+ // Structural validation only — no need to resolve secrets.
+ let Some(pipeline) = ci.load(&commit, std::collections::HashMap::new())? else {
println!("No ci.fnl found at {}.", commit.display);
return Ok(());
};
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 32f0e05..3b5ed1a 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -17,11 +17,13 @@ pub struct CommitRef {
pub display: String,
}
+use std::collections::HashMap;
use std::path::PathBuf;
use crate::Result;
use crate::event::{PushEvent, PushRef};
use crate::quire::Repo;
+use crate::secret::SecretString;
/// Path to the CI config within a bare repo, relative to the repo root.
pub const CI_FNL: &str = ".quire/ci.fnl";
@@ -47,17 +49,25 @@ impl Ci {
/// Load ci.fnl at a given SHA and return the validated pipeline.
///
+ /// `secrets` is the map of named secrets exposed to the script via
+ /// `(ci:secret …)`; pass an empty map for structural validation that
+ /// does not need to resolve secrets at registration time.
+ ///
/// Returns `Ok(None)` if the repo has no ci.fnl at that commit.
/// Errors if the Fennel source fails to parse/evaluate or if the
/// resulting job graph violates any structural rule.
- pub fn load(&self, commit: &CommitRef) -> Result<Option<Pipeline>> {
+ pub fn load(
+ &self,
+ commit: &CommitRef,
+ secrets: HashMap<String, SecretString>,
+ ) -> Result<Option<Pipeline>> {
let Some(source) = self.source(&commit.sha)? else {
return Ok(None);
};
let fennel = crate::fennel::Fennel::new()?;
let name = CI_FNL.to_string();
let lua_name = format!("{}:{CI_FNL}", commit.sha);
- let pipeline = pipeline::load(&fennel, &source, &lua_name, &name)?;
+ let pipeline = pipeline::load(&fennel, &source, &lua_name, &name, secrets)?;
Ok(Some(pipeline))
}
@@ -108,8 +118,20 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
}
};
+ // Pull the secrets map up front; missing global config means no
+ // secrets are available, but a present-but-broken config is a real
+ // error and aborts the trigger.
+ let secrets = match quire.global_config() {
+ Ok(c) => c.secrets,
+ Err(crate::Error::ConfigNotFound(_)) => HashMap::new(),
+ Err(e) => {
+ tracing::error!(repo = %event.repo, %e, "failed to load global config");
+ return;
+ }
+ };
+
for push_ref in event.updated_refs() {
- if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref) {
+ if let Err(e) = trigger_ref(&repo, event.pushed_at, push_ref, secrets.clone()) {
tracing::error!(
repo = %event.repo,
sha = %push_ref.new_sha,
@@ -121,7 +143,12 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
}
/// Create and run CI for a single updated ref.
-fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> Result<()> {
+fn trigger_ref(
+ repo: &Repo,
+ pushed_at: jiff::Timestamp,
+ push_ref: &PushRef,
+ secrets: HashMap<String, SecretString>,
+) -> Result<()> {
let ci = repo.ci();
let Some(source) = ci.source(&push_ref.new_sha)? else {
@@ -149,7 +176,7 @@ fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> R
let name = CI_FNL.to_string();
let lua_name = format!("{}:{CI_FNL}", push_ref.new_sha);
- match pipeline::load(&fennel, &source, &lua_name, &name) {
+ match pipeline::load(&fennel, &source, &lua_name, &name, secrets) {
Ok(_pipeline) => run.transition(RunState::Complete)?,
Err(e) => {
run.transition(RunState::Failed)?;
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index 9c81657..7c7c16f 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -1,6 +1,7 @@
//! CI job graph: evaluation of `ci.fnl` and validation rules.
use std::cell::RefCell;
+use std::collections::HashMap;
use std::rc::Rc;
use miette::{NamedSource, SourceSpan};
@@ -8,6 +9,7 @@ use mlua::UserData;
use crate::Result;
use crate::fennel::Fennel;
+use crate::secret::SecretString;
/// A registered job extracted from ci.fnl.
///
@@ -23,8 +25,8 @@ pub struct Job {
/// location for both per-job and post-graph diagnostics.
pub(crate) span: SourceSpan,
/// The job's run function from the Lua VM.
- /// Stored for future execution — not yet called.
- #[expect(dead_code)]
+ /// Currently exercised only from tests until the runtime executor lands.
+ #[allow(dead_code)]
pub(crate) run_fn: mlua::Function,
}
@@ -82,10 +84,12 @@ impl Pipeline {
/// ```fennel
/// (local ci (require :quire.ci))
/// (ci:job :build [:quire/push] (fn [_] nil))
+/// (ci:secret :github_token)
/// ```
struct CiModule {
jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
source: Rc<String>,
+ secrets: Rc<HashMap<String, SecretString>>,
}
impl UserData for CiModule {
@@ -104,6 +108,20 @@ impl UserData for CiModule {
Ok(())
},
);
+
+ // (ci:secret :name) — resolve a named secret declared in global
+ // config and return the string value. Errors as a Lua error if
+ // the name is undeclared or the file form fails to read.
+ methods.add_method("secret", |_, this, name: String| {
+ let secret = this
+ .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)
+ });
}
}
@@ -120,8 +138,9 @@ pub(crate) fn load(
source: &str,
filename: &str,
display: &str,
+ secrets: HashMap<String, SecretString>,
) -> Result<Pipeline> {
- let results = parse(fennel, source, filename, display)?;
+ let results = parse(fennel, source, filename, display, secrets)?;
let mut errors = Vec::new();
let mut jobs = Vec::new();
@@ -156,9 +175,11 @@ fn parse(
source: &str,
filename: &str,
display: &str,
+ secrets: HashMap<String, SecretString>,
) -> Result<Vec<std::result::Result<Job, ValidationError>>> {
let jobs = Rc::new(RefCell::new(Vec::new()));
let src = Rc::new(source.to_string());
+ let secrets = Rc::new(secrets);
fennel.eval_raw(source, filename, display, |lua| {
let loaded: mlua::Table = lua.globals().get::<mlua::Table>("package")?.get("loaded")?;
@@ -167,6 +188,7 @@ fn parse(
CiModule {
jobs: jobs.clone(),
source: src.clone(),
+ secrets: secrets.clone(),
},
)?;
Ok(())
@@ -345,7 +367,7 @@ mod tests {
let f = fennel();
let source = r#"(local ci (require :quire.ci))
(ci:job :test [:quire/push] (fn [_] nil))"#;
- let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("load should succeed");
+ let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
let jobs = pipeline.jobs();
assert_eq!(jobs.len(), 1);
assert_eq!(jobs[0].id, "test");
@@ -360,7 +382,7 @@ mod tests {
(ci:job :build [:quire/push] (fn [_] nil))
(ci:job :test [:build] (fn [_] nil))
"#;
- let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("load should succeed");
+ let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
let jobs = pipeline.jobs();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].id, "build");
@@ -378,7 +400,7 @@ mod tests {
(ci:job :sixth [:quire/push] (fn [_] nil))";
- let pipeline = load(&f, source, "ci.fnl", "ci.fnl").expect("load should succeed");
+ let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("load should succeed");
let lines: Vec<usize> = pipeline
.jobs()
.iter()
@@ -390,7 +412,7 @@ mod tests {
#[test]
fn load_errors_on_bad_fennel() {
let f = fennel();
- let result = load(&f, "{:bad {:}", "ci.fnl", "ci.fnl");
+ let result = load(&f, "{:bad {:}", "ci.fnl", "ci.fnl", HashMap::new());
assert!(result.is_err(), "malformed Fennel should fail");
}
@@ -399,7 +421,7 @@ mod tests {
/// `Err(ValidationError)`.
fn parse_results(source: &str) -> Vec<std::result::Result<Job, ValidationError>> {
let f = fennel();
- parse(&f, source, "ci.fnl", "ci.fnl").expect("parse should succeed")
+ parse(&f, source, "ci.fnl", "ci.fnl", HashMap::new()).expect("parse should succeed")
}
/// Discard parse errors and return only the successfully registered
@@ -512,6 +534,43 @@ mod tests {
);
}
+ #[test]
+ fn ci_secret_returns_resolved_value() {
+ let f = fennel();
+ let mut secrets = HashMap::new();
+ secrets.insert(
+ "github_token".to_string(),
+ SecretString::from_plain("ghp_test_value"),
+ );
+ let source = r#"(local ci (require :quire.ci))
+(ci:job :grab [:quire/push] (fn [_] (ci:secret :github_token)))"#;
+ let pipeline = load(&f, source, "ci.fnl", "ci.fnl", secrets)
+ .expect("load should succeed");
+ let token: String = pipeline.jobs()[0]
+ .run_fn
+ .call(())
+ .expect("run_fn should return the secret value");
+ assert_eq!(token, "ghp_test_value");
+ }
+
+ #[test]
+ fn ci_secret_errors_for_unknown_name() {
+ let f = fennel();
+ let source = r#"(local ci (require :quire.ci))
+(ci:job :grab [:quire/push] (fn [_] (ci:secret :missing)))"#;
+ let pipeline = load(&f, source, "ci.fnl", "ci.fnl", HashMap::new())
+ .expect("load should succeed");
+ let err = pipeline.jobs()[0]
+ .run_fn
+ .call::<mlua::Value>(())
+ .unwrap_err();
+ let msg = err.to_string();
+ assert!(
+ msg.contains("unknown secret") && msg.contains("missing"),
+ "expected unknown-secret error mentioning the name, got: {msg}"
+ );
+ }
+
#[test]
fn validate_rejects_unreachable_jobs() {
// An orphan with non-empty self-input passes pre-graph rules
diff --git a/src/error.rs b/src/error.rs
index 736c976..69cf2a0 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -13,6 +13,9 @@ pub enum Error {
#[error("secret resolution failed: {0}")]
SecretResolve(String),
+ #[error("unknown secret: {0:?}")]
+ UnknownSecret(String),
+
#[error("config not found: {0}")]
ConfigNotFound(String),