Mirror pushes to multiple remotes
Generalize the server-side mirror from a single hardcoded GitHub remote
to a set of targets, so each push can fan out to GitHub, Gitea, and any
other token-authenticated HTTPS remote at once.

Per-repo `.quire/config.fnl` now carries a `:mirrors` table keyed by
remote URL, each value naming a push token in the global `:secrets` map.
The old `:github :mirror` / `:github :mirror-token` keys are gone,
removing the provider-specific config namespace entirely.

Both forges accept the same token-only Basic-auth form
(`token:x-oauth-basic`), so mirroring needs no per-provider branching. A
target naming an unknown secret now surfaces an error instead of being
silently skipped; other targets still run.

Assisted-by: Claude Opus 4.8
change spuurupsorptkxlrwlwzquyqooouqvmm
commit 60de7b227aaeabfd54fa4a63f01096f8610562c3
author Alpha Chen <alpha@kejadlen.dev>
date
parent zttkxmrk
diff --git a/README.md b/README.md
index 20fda1c..5c3532a 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ A Rust binary that runs in a Docker container, fronted by the host's sshd and a
 - **Git hosting over SSH**, via the host's sshd dispatching into the container. Explicit repo creation (`ssh git@host quire repo new <name>`).
 - **A read-only web view** for browsing README, tree, history, blame, diffs, and refs.
 - **Fennel-based CI** (Fennel is a Lisp that compiles to Lua), with pipelines defined in `.quire/ci.fnl`. Unsandboxed by default since every pipeline is code I've written; a bubblewrap-based opt-in is available for the day quire ever runs code I haven't.
-- **Automatic GitHub mirroring** on every push. Set `:github :mirror` in `.quire/config.fnl` and `:github :mirror-token` in the global config; quire force-pushes each updated ref to the mirror URL independently of CI.
+- **Automatic mirroring** to GitHub, Gitea, or any HTTPS remote on every push. Map each remote URL to a push token in `.quire/config.fnl` under `:mirrors` (the value names an entry in the global `:secrets`); quire force-pushes every updated ref to all of them independently of CI.
 - **Email notifications** for CI failures and recoveries. SMTP via `msmtp`; plain text; per-repo config for what to send and to whom.
 
 No issues, no PRs, no user management, no webhooks. Use the GitHub mirror for the social stuff; quire is your forge.
diff --git a/docs/config.md b/docs/config.md
index 5dc440d..fc643c6 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -14,10 +14,12 @@ pick up changes.
 |---------------------------|----------------|----------|----------------------------------------------------------|
 | `:port`                   | integer        | no       | TCP port the HTTP server binds to (on `0.0.0.0`). Default: `3000`. |
 | `:sentry :dsn`            | `SecretString` | no       | Sentry DSN for error reporting from both `quire` and `quire-ci`. Omit to disable. |
-| `:secrets`                | table          | no       | Named secrets exposed to `ci.fnl` jobs as `(secret :name)`. |
-| `:github :mirror-token`   | `SecretString` | no       | Token used to authenticate pushes to per-repo GitHub mirrors. Required for mirroring to work; omit to disable. |
+| `:secrets`                | table          | no       | Named secrets exposed to `ci.fnl` jobs as `(secret :name)` and referenced by per-repo mirror targets. |
 
-Note: key names use hyphens, not underscores (e.g. `:mirror-token`, not `:mirror_token`).
+Note: multi-word key names use hyphens, not underscores (kebab-case).
+
+Mirror push tokens live in `:secrets`. Each per-repo mirror target names
+the secret holding its token (see [Per-repo config](#per-repo-config)).
 
 Minimal (no Sentry, no secrets):
 
@@ -25,11 +27,12 @@ Minimal (no Sentry, no secrets):
 {}
 ```
 
-With Sentry, secrets, and the token sourced from a Docker secret:
+With Sentry and mirror tokens sourced from Docker secrets:
 
 ```fennel
 {:sentry {:dsn "https://key@o0.ingest.sentry.io/0"}
- :secrets {:github_token {:file "/run/secrets/github_token"}}}
+ :secrets {:github-mirror {:file "/run/secrets/github_token"}
+           :gitea-mirror {:file "/run/secrets/gitea_token"}}}
 ```
 
 A missing file causes all settings to use their defaults. A malformed
@@ -49,14 +52,20 @@ tree:
 
 ### `.quire/config.fnl` schema
 
-| Key                  | Type   | Required | Purpose                                                        |
-|----------------------|--------|----------|----------------------------------------------------------------|
-| `:github :mirror`    | string | no       | HTTPS URL to mirror every pushed ref to (e.g. `"https://github.com/user/repo.git"`). Requires `:github :mirror-token` in the global config. |
+| Key             | Type  | Required | Purpose                                                        |
+|-----------------|-------|----------|----------------------------------------------------------------|
+| `:mirrors`      | table | no       | Remotes to force-push every updated ref to, keyed by HTTPS URL. Each value names the global `:secrets` entry holding that remote's push token. Empty or absent disables mirroring. |
+
+Each remote authenticates with HTTP Basic `token:x-oauth-basic`, which
+GitHub and Gitea both accept for a personal access token. A remote whose
+secret names no global secret fails that push and is reported; other
+remotes still run.
 
-Example:
+Example mirroring to both GitHub and Gitea:
 
 ```fennel
-{:github {:mirror "https://github.com/user/repo.git"}}
+{:mirrors {"https://github.com/user/repo.git" :github-mirror
+           "https://gitea.example/user/repo.git" :gitea-mirror}}
 ```
 
 The file is read via `git show <new-sha>:.quire/config.fnl`, so changes
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 8b05070..79fe6f7 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -115,7 +115,7 @@ async fn main() -> Result<()> {
         .with_type::<quire::Error>()
         .with_type::<quire::ci::Error>()
         .with_type::<quire_core::fennel::FennelError>()
-        .with_type::<quire::mirror::MirrorErrors>();
+        .with_type::<quire::mirror::MirrorError>();
     let _guard = telemetry::init_telemetry(
         miette_layer,
         FmtMode::AutoJson,
diff --git a/quire-server/src/mirror.rs b/quire-server/src/mirror.rs
index c369783..8282d61 100644
--- a/quire-server/src/mirror.rs
+++ b/quire-server/src/mirror.rs
@@ -2,115 +2,218 @@
 //!
 //! Triggered from the push event handler, independent of CI.
 
+use std::collections::HashMap;
+
 use miette::Diagnostic;
-use quire_core::event::{PushEvent, PushRef};
+use quire_core::event::PushEvent;
+use quire_core::secret::SecretString;
 use thiserror::Error;
 
-use crate::quire::Quire;
+use crate::quire::{Quire, Repo};
 
+/// A mirror failure: either we couldn't get started, or one or more targets
+/// failed after we did.
 #[derive(Debug, Error, Diagnostic)]
-#[error("mirror: {} ref(s) failed", self.errors.len())]
-pub struct MirrorErrors {
-    #[related]
-    errors: Vec<MirrorError>,
+pub enum MirrorError {
+    /// Couldn't resolve the repo from the push event — nothing was attempted.
+    #[error(transparent)]
+    Repo(#[from] crate::Error),
+
+    /// One or more mirror targets failed; the rest may have succeeded.
+    #[error("mirror: {} target(s) failed", errors.len())]
+    Targets {
+        #[related]
+        errors: Vec<TargetError>,
+    },
 }
 
+/// A failure mirroring one ref, carrying where it happened.
 #[derive(Debug, Error, Diagnostic)]
-enum MirrorError {
-    #[error("git push to {url} failed: {stderr}")]
-    PushFailed { url: String, stderr: String },
+pub enum TargetError {
+    /// Couldn't read `.quire/config.fnl` at the pushed ref.
+    #[error("reading .quire/config.fnl at {ref_name}: {source}")]
+    Config {
+        ref_name: String,
+        #[source]
+        source: crate::Error,
+    },
+
+    /// A push of one ref to one remote failed.
+    #[error("mirroring {} to {}: {cause}", .push.ref_name, .push.url)]
+    Push {
+        push: Push,
+        #[source]
+        cause: PushError,
+    },
+}
 
-    #[error(transparent)]
-    App(#[from] crate::Error),
+/// Why a single mirror push failed, before ref/url context is attached.
+#[derive(Debug, Error, Diagnostic)]
+pub enum PushError {
+    #[error("git rejected the push: {0}")]
+    Rejected(String),
 
     #[error(transparent)]
-    Io(#[from] std::io::Error),
+    Secret(#[from] quire_core::secret::Error),
+
+    #[error("running git push: {0}")]
+    Spawn(#[from] std::io::Error),
 }
 
-/// Mirror updated refs to a configured remote.
+/// Mirror updated refs to every remote configured for the repo.
 ///
-/// Reads `github.mirror-token` from global config for auth. For each updated
-/// ref, reads `.quire/config.fnl` at the new SHA to obtain the `github.mirror`
-/// URL. Skips repos with no mirror URL configured. If no token is configured,
-/// mirroring is skipped entirely.
-pub fn trigger(quire: &Quire, event: &PushEvent) -> Result<(), MirrorErrors> {
-    let errors = collect_errors(quire, event)?;
+/// For each updated ref, reads `.quire/config.fnl` at the new SHA to obtain
+/// the `:mirrors` table. Each target names a global `:secrets` entry holding
+/// its push token. Repos with no mirrors are skipped; a target naming a
+/// missing secret produces an error.
+pub fn trigger(quire: &Quire, event: &PushEvent) -> Result<(), MirrorError> {
+    let repo = quire.repo(&event.repo)?;
+    let mirror = Mirror {
+        repo: &repo,
+        secrets: &quire.config.secrets,
+    };
+
+    let (pushes, mut errors) = mirror.plan(event);
+    for push in pushes {
+        if let Err(cause) = mirror.run(&push) {
+            errors.push(TargetError::Push { push, cause });
+        }
+    }
+
     if errors.is_empty() {
         Ok(())
     } else {
-        Err(MirrorErrors { errors })
+        Err(MirrorError::Targets { errors })
     }
 }
 
-fn collect_errors(quire: &Quire, event: &PushEvent) -> Result<Vec<MirrorError>, MirrorErrors> {
-    let one = |e: MirrorError| MirrorErrors { errors: vec![e] };
-
-    let repo = quire
-        .repo(&event.repo)
-        .map_err(|e| one(MirrorError::App(e)))?;
-
-    let config = &quire.config;
-    let Some(mirror_token) = config
-        .github
-        .mirror_token
-        .as_ref()
-        .map(|s| s.reveal().map(str::to_owned))
-        .transpose()
-        .map_err(|e| one(MirrorError::App(crate::Error::from(e))))?
-    else {
-        return Ok(vec![]);
-    };
+/// One mirror push to perform: a ref pushed to a remote, authenticated with
+/// the named secret.
+#[derive(Debug)]
+pub struct Push {
+    ref_name: String,
+    url: String,
+    secret: String,
+}
 
-    Ok(event
-        .updated_refs()
-        .into_iter()
-        .filter_map(|push_ref| mirror_ref(&repo, push_ref, &mirror_token).err())
-        .collect())
+/// Mirroring bound to one repo and the global secrets it authenticates with.
+struct Mirror<'a> {
+    repo: &'a Repo,
+    secrets: &'a HashMap<String, SecretString>,
 }
 
-fn mirror_ref(
-    repo: &crate::quire::Repo,
-    push_ref: &PushRef,
-    token: &str,
-) -> Result<(), MirrorError> {
-    let repo_config = repo.repo_config(&push_ref.new_sha)?;
-    let Some(mirror_url) = repo_config.github.mirror else {
-        return Ok(());
-    };
+impl Mirror<'_> {
+    /// Expand each updated ref into one `Push` per configured mirror. A ref
+    /// whose config cannot be read yields a `Config` error and contributes
+    /// no pushes.
+    fn plan(&self, event: &PushEvent) -> (Vec<Push>, Vec<TargetError>) {
+        let mut pushes = Vec::new();
+        let mut errors = Vec::new();
+        for push_ref in event.updated_refs() {
+            match self.repo.repo_config(&push_ref.new_sha) {
+                Ok(config) => pushes.extend(config.mirrors.into_iter().map(|(url, secret)| Push {
+                    ref_name: push_ref.ref_name.clone(),
+                    url,
+                    secret,
+                })),
+                Err(source) => errors.push(TargetError::Config {
+                    ref_name: push_ref.ref_name.clone(),
+                    source,
+                }),
+            }
+        }
+        (pushes, errors)
+    }
 
-    // Force-push the ref to the mirror. The `+` prefix allows rewrites.
-    let refspec = format!("+{r}:{r}", r = push_ref.ref_name);
-
-    // Pass the auth token via git config env vars so it never appears in argv.
-    let out = repo
-        .git(&["push", "--porcelain", &mirror_url, &refspec])
-        .env("GIT_CONFIG_COUNT", "1")
-        .env("GIT_CONFIG_KEY_0", "http.extraHeader")
-        .env(
-            "GIT_CONFIG_VALUE_0",
-            format!(
-                "Authorization: Basic {}",
-                base64::Engine::encode(
-                    &base64::engine::general_purpose::STANDARD,
-                    format!("x-access-token:{token}"),
-                )
-            ),
+    /// Force-push the ref to the remote, reporting why the push failed.
+    fn run(&self, push: &Push) -> Result<(), PushError> {
+        let token = self.resolve_token(&push.secret)?;
+
+        // Force-push the ref to the mirror. The `+` prefix allows rewrites.
+        let refspec = format!("+{r}:{r}", r = push.ref_name);
+
+        // Pass the auth token via git config env vars so it never appears in argv.
+        let out = self
+            .repo
+            .git(&["push", "--porcelain", &push.url, &refspec])
+            .env("GIT_CONFIG_COUNT", "1")
+            .env("GIT_CONFIG_KEY_0", "http.extraHeader")
+            .env("GIT_CONFIG_VALUE_0", Self::auth_header(token))
+            .stdout(std::process::Stdio::piped())
+            .stderr(std::process::Stdio::piped())
+            .output()?;
+
+        if !out.status.success() {
+            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
+            return Err(PushError::Rejected(stderr));
+        }
+
+        tracing::info!(ref_name = %push.ref_name, mirror_url = %push.url, "mirror: push succeeded");
+        Ok(())
+    }
+
+    /// Resolve a named token from the global secrets map.
+    fn resolve_token(&self, name: &str) -> Result<&str, quire_core::secret::Error> {
+        self.secrets
+            .get(name)
+            .ok_or_else(|| quire_core::secret::Error::UnknownSecret(name.to_string()))?
+            .reveal()
+    }
+
+    /// Build the HTTP Basic `Authorization` header line for a push token.
+    ///
+    /// Uses the `token:x-oauth-basic` form, which GitHub and Gitea both accept
+    /// for git-over-HTTPS push with a personal access token.
+    fn auth_header(token: &str) -> String {
+        format!(
+            "Authorization: Basic {}",
+            base64::Engine::encode(
+                &base64::engine::general_purpose::STANDARD,
+                format!("{token}:x-oauth-basic"),
+            )
         )
-        .stdout(std::process::Stdio::piped())
-        .stderr(std::process::Stdio::piped())
-        .output()?;
-
-    if !out.status.success() {
-        return Err(MirrorError::PushFailed {
-            url: mirror_url,
-            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
-        });
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// `resolve_token` ignores the repo, so any valid `Repo` will do.
+    fn dummy_repo() -> Repo {
+        Repo::new(std::path::Path::new("/srv/repos"), "r.git").unwrap()
+    }
+
+    #[test]
+    fn auth_header_encodes_token_as_oauth_basic() {
+        // base64("tok:x-oauth-basic") == "dG9rOngtb2F1dGgtYmFzaWM=".
+        assert_eq!(
+            Mirror::auth_header("tok"),
+            "Authorization: Basic dG9rOngtb2F1dGgtYmFzaWM="
+        );
     }
 
-    tracing::info!(
-        ref_name = %push_ref.ref_name,
-        mirror_url = %mirror_url,
-        "mirror: push succeeded"
-    );
-    Ok(())
+    #[test]
+    fn resolve_token_returns_revealed_secret() {
+        let repo = dummy_repo();
+        let mut secrets = HashMap::new();
+        secrets.insert("gitea-mirror".to_string(), SecretString::from("s3cret"));
+        let mirror = Mirror {
+            repo: &repo,
+            secrets: &secrets,
+        };
+        assert_eq!(mirror.resolve_token("gitea-mirror").unwrap(), "s3cret");
+    }
+
+    #[test]
+    fn resolve_token_errors_on_missing_secret() {
+        let repo = dummy_repo();
+        let secrets = HashMap::new();
+        let mirror = Mirror {
+            repo: &repo,
+            secrets: &secrets,
+        };
+        let err = mirror.resolve_token("absent").unwrap_err();
+        assert!(matches!(err, quire_core::secret::Error::UnknownSecret(name) if name == "absent"));
+    }
 }
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 85bec74..47ef926 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
 use std::path::{Path, PathBuf};
 use std::sync::{Arc, Mutex, OnceLock};
 
@@ -31,9 +31,6 @@ pub struct GlobalConfig {
     /// CI configuration.
     #[serde(default)]
     pub ci: CiConfig,
-    /// GitHub integration settings.
-    #[serde(default)]
-    pub github: GlobalGithubConfig,
 }
 
 impl Default for GlobalConfig {
@@ -43,20 +40,10 @@ impl Default for GlobalConfig {
             secrets: HashMap::new(),
             port: default_port(),
             ci: CiConfig::default(),
-            github: GlobalGithubConfig::default(),
         }
     }
 }
 
-/// Global GitHub integration configuration.
-#[derive(serde::Deserialize, Debug, Default, Clone)]
-#[serde(rename_all = "kebab-case")]
-pub struct GlobalGithubConfig {
-    /// Bearer token used to authenticate push access to the mirror remote.
-    #[serde(default)]
-    pub mirror_token: Option<SecretString>,
-}
-
 fn default_port() -> u16 {
     3000
 }
@@ -202,16 +189,10 @@ impl Repo {
 #[derive(serde::Deserialize, Debug, Default, Clone)]
 #[serde(default, rename_all = "kebab-case")]
 pub struct RepoConfig {
-    pub github: RepoGithubConfig,
-}
-
-/// Per-repo GitHub configuration.
-#[derive(serde::Deserialize, Debug, Default, Clone)]
-#[serde(default, rename_all = "kebab-case")]
-pub struct RepoGithubConfig {
-    /// Remote URL to mirror every pushed ref to.
-    /// E.g. `"https://github.com/user/repo.git"`.
-    pub mirror: Option<String>,
+    /// Remotes to mirror every pushed ref to, keyed by remote URL. Each
+    /// value names the global `:secrets` entry holding that remote's push
+    /// token. Empty disables mirroring.
+    pub mirrors: BTreeMap<String, String>,
 }
 
 /// Application runtime context.
@@ -565,6 +546,35 @@ mod tests {
         );
     }
 
+    #[test]
+    fn repo_config_defaults_to_no_mirrors() {
+        let config: RepoConfig = Fennel::load_config_str("{}", ".quire/config.fnl").expect("parse");
+        assert!(config.mirrors.is_empty());
+    }
+
+    #[test]
+    fn repo_config_parses_mirror_targets() {
+        let source = r#"{:mirrors {"https://github.com/u/r.git" :github-mirror
+                                   "https://gitea.example/u/r.git" :gitea-mirror}}"#;
+        let config: RepoConfig =
+            Fennel::load_config_str(source, ".quire/config.fnl").expect("parse");
+        assert_eq!(config.mirrors.len(), 2);
+        assert_eq!(
+            config
+                .mirrors
+                .get("https://github.com/u/r.git")
+                .map(String::as_str),
+            Some("github-mirror")
+        );
+        assert_eq!(
+            config
+                .mirrors
+                .get("https://gitea.example/u/r.git")
+                .map(String::as_str),
+            Some("gitea-mirror")
+        );
+    }
+
     /// Helper: run a git subcommand in `cwd` with hermetic env, panicking on failure.
     fn git_in(cwd: &Path, args: &[&str]) {
         let output = std::process::Command::new("git")
diff --git a/quire-server/templates/config.html b/quire-server/templates/config.html
index ceddb6f..dfd40a9 100644
--- a/quire-server/templates/config.html
+++ b/quire-server/templates/config.html
@@ -49,17 +49,6 @@
       <span class="config-val config-val--empty">disabled</span>
     </div>
     {% endif %}
-    {% if let Some(token) = config.github.mirror_token %}
-    <div class="config-row">
-      <span class="config-key">github.mirror-token</span>
-      <span class="config-val">{{ token }}</span>
-    </div>
-    {% else %}
-    <div class="config-row">
-      <span class="config-key">github.mirror-token</span>
-      <span class="config-val config-val--empty">not set</span>
-    </div>
-    {% endif %}
     {% for (key, value) in sorted_secrets() %}
     <div class="config-row">
       <span class="config-key">secrets.{{ key }}</span>