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
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>