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
change utsoykvwkykuowkkluostqmznnwmowyl
commit e2ea2cafc5770a8729eddb9d747619903bfc234c
author Alpha Chen <alpha@kejadlen.dev>
date
parent zwyqwlyq
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),