From f310b1ce5a3774f2e8f9af234e860386811172fe Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 3 Jul 2026 00:24:52 +0200 Subject: [PATCH] =?UTF-8?q?config=20mirror:=20never=20force-push=20?= =?UTF-8?q?=E2=80=94=20keep=20forge=20history=20on=20rolled-back=20deploys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/forge.md | 8 +++++--- hive-c0re/src/forge.rs | 32 ++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/forge.md b/docs/forge.md index 2e40ed48..0ea3f431 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -57,9 +57,11 @@ Two things live in the `agent-configs` Forgejo organization: `main` is branch-protected core-only: only hive-c0re's verify-and-ff-push merge handler lands on `main`, an operator-team approval is required, and the agent can neither push `main` directly nor self-merge. `main` is - fast-forward-only — no auto force-push (the merge handler's ff push lands - fine; the legacy `push_config` mirror, which force-pushes to re-point status - tags and rewind on rollback, runs best-effort until the PR flow retires it). + fast-forward-only — hive-c0re never force-pushes (the merge handler's ff + push lands fine; the `push_config` mirror pushes `main` + the add-only + status tags without force, and treats a non-fast-forward rejection of + `main` after a rolled-back deploy as expected — the forge keeps the + approved history, the `failed/` tag records the divergence). Repos stay private, so an agent can't read another agent's config. (Agents remain read-only collaborators on `core/meta`.) - The dashboard links each container's "config" anchor to this diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 85a9d90e..c16e32b9 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -37,8 +37,8 @@ const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; /// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge /// handler lands on it (operator approval required; the agent can't push /// `main` or self-merge). The repos remain private, so an agent still can't -/// reach *another* agent's config. `main` is fast-forward-only (no auto -/// force-push); the legacy `push_config` mirror runs best-effort until the +/// reach *another* agent's config. `main` is fast-forward-only — hive-c0re +/// never force-pushes; the `push_config` mirror runs best-effort until the /// PR-merge flow retires it. const CONFIG_ORG: &str = "agent-configs"; /// Forgejo org hosting the operator-curated shared docs/skills repo @@ -855,8 +855,20 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> { /// forge isn't seeded or the applied repo doesn't exist yet. /// /// Call this after every hive-c0re mutation of an applied repo's refs -/// so the forge copy always reflects what core actually did. `--force` -/// because a failed build rolls `main` backwards to the last-good sha. +/// so the forge copy always reflects what core actually did. +/// +/// Never force-pushes. The status tags are id-suffixed +/// (`proposal/`, `deployed/`, …) 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/` 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. /// /// The tokenised URL is passed straight to `git push` and deliberately /// never stored as a named remote: the applied repo is bind-mounted @@ -875,7 +887,6 @@ pub async fn push_config(name: &str) -> Result<()> { .current_dir(&dir) .args([ "push", - "--force", &url, "refs/heads/main:refs/heads/main", "refs/tags/*:refs/tags/*", @@ -884,10 +895,19 @@ pub async fn push_config(name: &str) -> Result<()> { .await .context("invoke git push agent-configs")?; if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + if stderr.contains("non-fast-forward") { + 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" + ); + return Ok(()); + } anyhow::bail!( "git push {CONFIG_ORG}/{name} failed ({}): {}", out.status, - String::from_utf8_lossy(&out.stderr).trim() + stderr.trim() ); } tracing::info!(%name, "forge: mirrored applied config to agent-configs");