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