Add server-side GitHub mirror support
Triggered directly from push events (not CI): when a ref matches the
configured branch, quire-server creates a date-sha tag locally and
pushes both the branch and tag to the configured mirror remote.

- quire-core: add `ci::repo_config` with `RepoConfig` / `GithubRepoConfig`
  (mirror URL and branch, loaded from `.quire/config.fnl`)
- quire-server: add `GlobalGithubConfig` with `mirror_token` to `GlobalConfig`
- quire-server: add `Repo::repo_config(sha)` to read per-repo config via git show
- quire-server: add `mirror` module with `trigger` fn; wire after `ci::trigger`

https://claude.ai/code/session_01MtUMXi7Z3GCDWQFY8puWpu
change
commit 635fe32062799df9ef8ca2193b245ba3471a854b
author Claude <noreply@anthropic.com>
date
parent f87911d2
diff --git a/quire-core/src/ci/mod.rs b/quire-core/src/ci/mod.rs
index 6f417fc..eaeb2ee 100644
--- a/quire-core/src/ci/mod.rs
+++ b/quire-core/src/ci/mod.rs
@@ -10,5 +10,6 @@ pub mod event;
 pub mod logs;
 pub mod pipeline;
 pub mod registration;
+pub mod repo_config;
 pub mod run;
 pub mod runtime;
diff --git a/quire-core/src/ci/repo_config.rs b/quire-core/src/ci/repo_config.rs
new file mode 100644
index 0000000..a1899c9
--- /dev/null
+++ b/quire-core/src/ci/repo_config.rs
@@ -0,0 +1,76 @@
+//! Per-repo CI configuration parsed from `.quire/config.fnl`.
+//!
+//! Loaded at run time (inside `quire-ci`) from the materialized
+//! workspace. Missing file → all defaults.
+
+/// Per-repo CI configuration.
+#[derive(serde::Deserialize, Debug, Default, Clone)]
+#[serde(default, rename_all = "kebab-case")]
+pub struct RepoConfig {
+    pub github: GithubRepoConfig,
+}
+
+/// Per-repo GitHub configuration.
+#[derive(serde::Deserialize, Debug, Clone)]
+#[serde(default, rename_all = "kebab-case")]
+pub struct GithubRepoConfig {
+    /// Remote URL to mirror to on every push to `:branch`.
+    /// E.g. `"https://github.com/user/repo.git"`.
+    /// When set, quire injects a `quire/mirror` built-in job into
+    /// every pipeline for this repo.
+    pub mirror: Option<String>,
+    /// Ref that triggers the mirror (default: `refs/heads/main`).
+    /// Pushes to any other ref are ignored by the mirror job.
+    pub branch: String,
+}
+
+impl Default for GithubRepoConfig {
+    fn default() -> Self {
+        Self {
+            mirror: None,
+            branch: "refs/heads/main".to_string(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn load(source: &str) -> RepoConfig {
+        crate::fennel::Fennel::new()
+            .expect("Fennel::new")
+            .load_string(source, "config.fnl")
+            .expect("load_string")
+    }
+
+    #[test]
+    fn defaults_when_empty_table() {
+        let cfg = load("{}");
+        assert!(cfg.github.mirror.is_none());
+        assert_eq!(cfg.github.branch, "refs/heads/main");
+    }
+
+    #[test]
+    fn parses_mirror_url() {
+        let cfg = load(r#"{:github {:mirror "https://github.com/user/repo.git"}}"#);
+        assert_eq!(
+            cfg.github.mirror.as_deref(),
+            Some("https://github.com/user/repo.git")
+        );
+    }
+
+    #[test]
+    fn parses_custom_branch() {
+        let cfg = load(
+            r#"{:github {:mirror "https://github.com/u/r.git" :branch "refs/heads/release"}}"#,
+        );
+        assert_eq!(cfg.github.branch, "refs/heads/release");
+    }
+
+    #[test]
+    fn default_branch_when_only_mirror_set() {
+        let cfg = load(r#"{:github {:mirror "https://github.com/u/r.git"}}"#);
+        assert_eq!(cfg.github.branch, "refs/heads/main");
+    }
+}
diff --git a/quire-server/src/bin/quire/server.rs b/quire-server/src/bin/quire/server.rs
index 4c41bb6..d0fd40d 100644
--- a/quire-server/src/bin/quire/server.rs
+++ b/quire-server/src/bin/quire/server.rs
@@ -11,6 +11,7 @@ use miette::{Context, IntoDiagnostic, Result};
 use quire::Quire;
 use quire::ci;
 use quire::event::PushEvent;
+use quire::mirror;
 use tower_http::trace::TraceLayer;
 use tracing::info_span;
 
@@ -156,4 +157,5 @@ async fn handle_event_connection(mut stream: tokio::net::UnixStream, quire: Quir
     }
 
     ci::trigger(&quire, &event);
+    mirror::trigger(&quire, &event);
 }
diff --git a/quire-server/src/lib.rs b/quire-server/src/lib.rs
index 781c637..532b1ac 100644
--- a/quire-server/src/lib.rs
+++ b/quire-server/src/lib.rs
@@ -2,6 +2,7 @@ pub mod ci;
 pub mod db;
 mod error;
 pub mod event;
+pub mod mirror;
 pub mod quire;
 
 pub use quire_core::telemetry::SentryConfig;
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
new file mode 100644
index 0000000..d8ccd20
--- /dev/null
+++ b/quire-server/src/mirror.rs
@@ -0,0 +1,185 @@
+//! Server-side mirror: push a branch (and a version tag) to a remote on every push.
+//!
+//! Triggered from the push event handler, independent of CI.
+
+use quire_core::event::PushEvent;
+
+use crate::quire::Quire;
+
+/// Mirror updated refs to a configured remote.
+///
+/// Reads `github.mirror-token` from global config for auth. For each updated
+/// ref, reads `.quire/config.fnl` at the new SHA to obtain `github.mirror` (URL)
+/// and `github.branch` (the ref that triggers mirroring). Skips refs that don't
+/// match the configured branch, and repos with no mirror URL set.
+pub fn trigger(quire: &Quire, event: &PushEvent) {
+    let repo = match quire.repo(&event.repo) {
+        Ok(r) if r.exists() => r,
+        Ok(_) => {
+            tracing::warn!(repo = %event.repo, "mirror: repo not found on disk");
+            return;
+        }
+        Err(e) => {
+            tracing::error!(repo = %event.repo, error = %e, "mirror: invalid repo name");
+            return;
+        }
+    };
+
+    let config = match quire.global_config() {
+        Ok(c) => c,
+        Err(e) => {
+            tracing::error!(
+                repo = %event.repo,
+                error = &e as &(dyn std::error::Error + 'static),
+                "mirror: failed to load global config",
+            );
+            return;
+        }
+    };
+
+    let mirror_token = match config.github.mirror_token {
+        None => None,
+        Some(ref secret) => match secret.reveal() {
+            Ok(t) => Some(t.to_string()),
+            Err(e) => {
+                tracing::error!(
+                    error = &e as &(dyn std::error::Error + 'static),
+                    "mirror: failed to reveal mirror token",
+                );
+                return;
+            }
+        },
+    };
+
+    for push_ref in event.updated_refs() {
+        let repo_config = match repo.repo_config(&push_ref.new_sha) {
+            Ok(c) => c,
+            Err(e) => {
+                tracing::warn!(
+                    ref_name = %push_ref.ref_name,
+                    sha = %push_ref.new_sha,
+                    error = &e as &(dyn std::error::Error + 'static),
+                    "mirror: failed to read repo config, skipping ref",
+                );
+                continue;
+            }
+        };
+
+        let Some(mirror_url) = repo_config.github.mirror else {
+            continue;
+        };
+
+        if push_ref.ref_name != repo_config.github.branch {
+            continue;
+        }
+
+        push_to_mirror(
+            &repo,
+            &push_ref.new_sha,
+            &push_ref.ref_name,
+            &mirror_url,
+            mirror_token.as_deref(),
+        );
+    }
+}
+
+fn push_to_mirror(
+    repo: &crate::quire::Repo,
+    sha: &str,
+    ref_name: &str,
+    mirror_url: &str,
+    token: Option<&str>,
+) {
+    let tag = make_tag(sha);
+
+    // Create the tag locally; ignore "already exists" errors.
+    let tag_out = repo
+        .git(&["tag", &tag, sha])
+        .stdout(std::process::Stdio::null())
+        .stderr(std::process::Stdio::piped())
+        .output();
+    match tag_out {
+        Err(e) => {
+            tracing::error!(sha, tag, error = %e, "mirror: failed to run git tag");
+            return;
+        }
+        Ok(out) if !out.status.success() => {
+            let stderr = String::from_utf8_lossy(&out.stderr);
+            if !stderr.contains("already exists") {
+                tracing::error!(sha, tag, %stderr, "mirror: git tag failed");
+                return;
+            }
+        }
+        Ok(_) => {}
+    }
+
+    // Force-push the branch and push the tag.
+    // The `+` prefix in the branch refspec allows fast-forward and force pushes.
+    let refspec_branch = format!("+{ref_name}:{ref_name}");
+    let refspec_tag = format!("refs/tags/{tag}:refs/tags/{tag}");
+    let mut cmd = repo.git(&[
+        "push",
+        "--porcelain",
+        mirror_url,
+        &refspec_branch,
+        &refspec_tag,
+    ]);
+
+    // Pass the auth token via git config env vars so it never appears in argv.
+    if let Some(token) = token {
+        cmd.env("GIT_CONFIG_COUNT", "1")
+            .env("GIT_CONFIG_KEY_0", "http.extraHeader")
+            .env("GIT_CONFIG_VALUE_0", format!("Authorization: Bearer {token}"));
+    }
+
+    match cmd
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::piped())
+        .output()
+    {
+        Ok(out) if out.status.success() => {
+            tracing::info!(ref_name, tag, mirror_url, "mirror: push succeeded");
+        }
+        Ok(out) => {
+            tracing::error!(
+                ref_name,
+                tag,
+                mirror_url,
+                stderr = %String::from_utf8_lossy(&out.stderr),
+                "mirror: push failed",
+            );
+        }
+        Err(e) => {
+            tracing::error!(ref_name, mirror_url, error = %e, "mirror: failed to run git push");
+        }
+    }
+}
+
+fn make_tag(sha: &str) -> String {
+    let date = jiff::Timestamp::now().strftime("%Y-%m-%d").to_string();
+    let sha8 = &sha[..sha.len().min(8)];
+    format!("v{date}-{sha8}")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn make_tag_format() {
+        let sha = "abc12345def67890";
+        let tag = make_tag(sha);
+        assert!(tag.starts_with('v'), "tag should start with 'v': {tag}");
+        // v<date>-<sha8>
+        let parts: Vec<&str> = tag.splitn(2, '-').collect();
+        assert_eq!(parts.len(), 2);
+        assert_eq!(&tag[tag.len() - 8..], "abc12345");
+    }
+
+    #[test]
+    fn make_tag_short_sha() {
+        let sha = "abc";
+        let tag = make_tag(sha);
+        assert!(tag.ends_with("abc"), "short sha should be used as-is: {tag}");
+    }
+}
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 0ce1b0a..93493f7 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -33,6 +33,20 @@ pub struct GlobalConfig {
     /// CI configuration.
     #[serde(default)]
     pub ci: CiConfig,
+    /// GitHub integration settings.
+    #[serde(default)]
+    pub github: GlobalGithubConfig,
+}
+
+/// Global GitHub integration configuration.
+#[derive(serde::Deserialize, Debug, Default)]
+#[serde(rename_all = "kebab-case")]
+pub struct GlobalGithubConfig {
+    /// Bearer token used to authenticate push access to the mirror remote.
+    /// Exposed to the built-in `quire/mirror` job as the
+    /// `"github/mirror-token"` secret.
+    #[serde(default)]
+    pub mirror_token: Option<SecretString>,
 }
 
 fn default_port() -> u16 {
@@ -144,6 +158,33 @@ impl Repo {
         Ci::new(self.path())
     }
 
+    /// Read and parse `.quire/config.fnl` at the given commit SHA.
+    ///
+    /// Returns defaults if the file does not exist at that commit.
+    pub fn repo_config(
+        &self,
+        sha: &str,
+    ) -> AppResult<quire_core::ci::repo_config::RepoConfig> {
+        let output = self
+            .git(&["show", &format!("{sha}:.quire/config.fnl")])
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .output()?;
+
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr);
+            if stderr.contains("does not exist") || stderr.contains("not found") {
+                return Ok(quire_core::ci::repo_config::RepoConfig::default());
+            }
+            return Err(Error::Io(std::io::Error::other(format!(
+                "failed to read .quire/config.fnl at {sha}: {stderr}"
+            ))));
+        }
+
+        let source = String::from_utf8(output.stdout)?;
+        Ok(Fennel::new()?.load_string(&source, ".quire/config.fnl")?)
+    }
+
     /// The base directory for CI runs (`runs/<repo>/`).
     pub fn runs_base(&self) -> PathBuf {
         self.quire_root.join("runs").join(&self.name)
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index b84318a..5ef2670 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -28,7 +28,7 @@ use crate::Quire;
 pub fn router(quire: Quire) -> axum::Router {
     let run_routes = axum::Router::new()
         .route("/bootstrap", axum::routing::get(get_bootstrap))
-        .route("/secrets/{name}", axum::routing::get(get_secret))
+        .route("/secrets/{*name}", axum::routing::get(get_secret))
         .layer(axum::middleware::from_fn_with_state(
             quire.clone(),
             verify_run_token,
@@ -204,7 +204,8 @@ async fn get_bootstrap(
 /// `GET /api/run/secrets/:name`
 ///
 /// Returns the plain-text value of a named secret from the global config.
-/// Auth is handled by [`verify_run_token`] middleware before this handler runs.
+/// Supports slash-separated names via the `{*name}` wildcard route.
+/// Auth is handled by [`verify_run_token`] middleware.
 /// Returns 404 if the secret is not declared in config.
 #[derive(serde::Deserialize)]
 struct SecretPath {
@@ -217,10 +218,10 @@ async fn get_secret(
 ) -> Result<axum::Json<serde_json::Value>, ApiError> {
     let value = tokio::task::spawn_blocking(move || -> std::result::Result<String, ApiError> {
         let config = quire.global_config()?;
-        match config.secrets.get(&name) {
-            Some(s) => Ok(s.reveal()?.to_string()),
-            None => Err(ApiError::NotFound),
+        if let Some(s) = config.secrets.get(&name) {
+            return Ok(s.reveal()?.to_string());
         }
+        Err(ApiError::NotFound)
     })
     .await
     .expect("blocking task panicked")?;