fix(#2553): mirror agent-config tags + main as separate pushes so a protected-main reject doesn't drop the status tags

This commit is contained in:
damocles 2026-07-17 12:02:05 +02:00
commit fc00e38490

View file

@ -415,27 +415,28 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> {
} }
} }
/// Mirror agent `name`'s applied config repo — `main` plus every tag /// Mirror agent `name`'s applied config repo — every status tag
/// (`proposal` / `approved` / `building` / `deployed` / `failed` / /// (`proposal` / `approved` / `building` / `deployed` / `failed` /
/// `denied`) — to `agent-configs/<name>` on the local forge. /// `denied`) plus a best-effort `main` — to `agent-configs/<name>` on
/// Best-effort: returns Err which callers log + ignore. No-op when the /// the local forge. Best-effort: returns Err which callers log + ignore.
/// forge isn't seeded or the applied repo doesn't exist yet. /// No-op when the forge isn't seeded or the applied repo doesn't exist.
/// ///
/// Call this after every hive-c0re mutation of an applied repo's refs /// Call this after every hive-c0re mutation of an applied repo's refs
/// so the forge copy always reflects what core actually did. /// so the forge copy always reflects what core actually did.
/// ///
/// Never force-pushes. The status tags are id-suffixed /// **Two separate pushes, not one.** The status tags are id-suffixed
/// (`proposal/<id>`, `deployed/<id>`, …) and therefore add-only, and /// (`deployed/<id>`, …) and add-only, so they must always land. `main`,
/// `main` is published history — after a failed deploy rolls the LOCAL /// by contrast, is core-only branch-protected and authoritatively
/// applied `main` back to last-good, the forge `main` may legitimately /// advanced by `pr_merge`'s ff-only merge API — so a mirror push of an
/// be ahead (e.g. an operator-merged config PR whose rebuild failed). /// already-established `main` is routinely rejected (protected-branch,
/// Rewinding it would erase that merged commit from the forge, which /// or non-fast-forward after a rolled-back deploy). Git's pre-receive
/// is exactly the incident this guards against: the local repo tracks /// hook is all-or-nothing: bundling both refspecs in one push means that
/// "what last built", the forge tracks "what was approved", and the /// `main` reject declines the whole push, dropping the tags too.
/// `failed/<id>` tag records the divergence. A non-fast-forward /// So we push the tags on their own first, then attempt `main`
/// rejection of `main` is therefore expected + logged at info; the /// separately and swallow the expected reject. The `main` push still
/// tags in the same push still land (git pushes refspecs /// matters for the initial seed of a fresh (empty) config repo, where it
/// independently). Any other failure is a real error. /// creates `main`. Operator-driven divergence fix: the `hivectl forge
/// reconcile-config` command.
/// ///
/// The tokenised URL is passed straight to `git push` and deliberately /// The tokenised URL is passed straight to `git push` and deliberately
/// never stored as a named remote: the applied repo is bind-mounted /// never stored as a named remote: the applied repo is bind-mounted
@ -450,29 +451,34 @@ pub async fn push_config(name: &str) -> Result<()> {
return Ok(()); return Ok(());
} }
let url = forge_git_url(&token, &format!("{CONFIG_ORG}/{name}")); let url = forge_git_url(&token, &format!("{CONFIG_ORG}/{name}"));
let out = crate::lifecycle::git_command() // Tags first, in their own push, so they land regardless of main's fate.
.current_dir(&dir) let out = run_config_push(&dir, &url, "refs/tags/*:refs/tags/*").await?;
.args([ if !out.status.success() {
"push", anyhow::bail!(
&url, "git push tags {CONFIG_ORG}/{name} failed ({}): {}",
"refs/heads/main:refs/heads/main", out.status,
"refs/tags/*:refs/tags/*", String::from_utf8_lossy(&out.stderr).trim()
]) );
.output() }
.await // Then main on its own — a protected-branch / non-ff reject of an
.context("invoke git push agent-configs")?; // established main is expected (pr_merge owns main); only the initial
// empty-repo seed actually advances it here.
let out = run_config_push(&dir, &url, "refs/heads/main:refs/heads/main").await?;
if !out.status.success() { if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr); let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("non-fast-forward") { if stderr.contains("non-fast-forward")
|| stderr.contains("protected branch")
|| stderr.contains("pre-receive hook declined")
{
tracing::info!( tracing::info!(
%name, %name,
"forge: mirror push of main rejected (non-fast-forward) — forge main is \ "forge: mirror push of main skipped — forge main is protected / \
ahead of local applied main (rolled-back deploy); leaving forge history intact" owned by the config-PR ff-merge (expected); tags mirrored"
); );
return Ok(()); return Ok(());
} }
anyhow::bail!( anyhow::bail!(
"git push {CONFIG_ORG}/{name} failed ({}): {}", "git push main {CONFIG_ORG}/{name} failed ({}): {}",
out.status, out.status,
stderr.trim() stderr.trim()
); );
@ -481,6 +487,22 @@ pub async fn push_config(name: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Run a single `git push <url> <refspec>` in the applied repo `dir` and
/// return the raw output for the caller to classify. Split out so
/// [`push_config`] can push tags and `main` as independent pushes.
async fn run_config_push(
dir: &std::path::Path,
url: &str,
refspec: &str,
) -> Result<std::process::Output> {
crate::lifecycle::git_command()
.current_dir(dir)
.args(["push", url, refspec])
.output()
.await
.context("invoke git push agent-configs")
}
/// Create an org named `name` (`org_create`). Idempotent: HTTP 422 /// Create an org named `name` (`org_create`). Idempotent: HTTP 422
/// ("user already exists") / 409 is treated as success. /// ("user already exists") / 409 is treated as success.
pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {