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
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")?;