fix(forge): config-repo main is fast-forward-only (no auto force-push)

Per operator directive: a silent automatic force-push is a bug. The
config-repo merge path already never force-pushes (run_merge_config_pr
lands via ff_push_to_main, a non-force git push). So set the branch
protection's enable_force_push to false — main only ever advances by
fast-forward.

The legacy push_config mirror does force-push (it re-points the status
tags and rewinds main on a failed-build rollback); the protection now
rejects those non-ff updates, so the mirror runs best-effort until the
agent-opened PR-merge flow retires it. Docs + comments updated to match.
This commit is contained in:
atlas 2026-06-23 22:24:23 +02:00 committed by mara
commit 5caec9c1a9
2 changed files with 18 additions and 16 deletions

View file

@ -56,10 +56,11 @@ Two things live in the `agent-configs` Forgejo organization:
config-change branches and (once #1838 P2 lands) open config PRs — but config-change branches and (once #1838 P2 lands) open config PRs — but
`main` is branch-protected core-only: only hive-c0re's verify-and-ff-push `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 merge handler lands on `main`, an operator-team approval is required, and
the agent can neither push `main` directly nor self-merge. During the the agent can neither push `main` directly nor self-merge. `main` is
transition c0re still force-mirrors the agent's applied config repo here fast-forward-only — no auto force-push (the merge handler's ff push lands
on each `↻ R3BU1LD` (the `enable_force_push` allowance), until the PR fine; the legacy `push_config` mirror, which force-pushes to re-point status
flow replaces that. Repos stay private, so an agent can't read another tags and rewind on rollback, runs best-effort until the PR flow retires it).
Repos stay private, so an agent can't read another
agent's config. (Agents remain read-only collaborators on `core/meta`.) agent's config. (Agents remain read-only collaborators on `core/meta`.)
- The dashboard links each container's "config" anchor to this - The dashboard links each container's "config" anchor to this
config repo, so operators can click straight from the SW4RM tab into config repo, so operators can click straight from the SW4RM tab into

View file

@ -37,8 +37,9 @@ 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 /// 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 /// 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 /// `main` or self-merge). The repos remain private, so an agent still can't
/// reach *another* agent's config. During the transition `push_config` also /// reach *another* agent's config. `main` is fast-forward-only (no auto
/// force-mirrors `applied → main` here until the PR-merge flow replaces it. /// force-push); the legacy `push_config` mirror runs best-effort until the
/// PR-merge flow retires it.
const CONFIG_ORG: &str = "agent-configs"; const CONFIG_ORG: &str = "agent-configs";
/// Forgejo org hosting the operator-curated shared docs/skills repo /// Forgejo org hosting the operator-curated shared docs/skills repo
/// that every agent gets read-only access to. Agents use it as a /// that every agent gets read-only access to. Agents use it as a
@ -708,7 +709,7 @@ pub async fn ensure_config_repo(name: &str) -> Result<()> {
// Agent = write collaborator: it can push config-PR branches + open PRs, // Agent = write collaborator: it can push config-PR branches + open PRs,
// but the branch protection below keeps it off `main` directly. // but the branch protection below keeps it off `main` directly.
add_collaborator(CONFIG_ORG, name, name, "write", &token).await?; add_collaborator(CONFIG_ORG, name, name, "write", &token).await?;
// Protect `main` core-only (+ transitional force-push for push_config). // Protect `main` core-only, fast-forward-only (no auto force-push).
apply_config_repo_branch_protection(name, &token).await apply_config_repo_branch_protection(name, &token).await
} }
@ -997,20 +998,20 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()>
/// (`run_merge_config_pr`). The agent can never push `main` directly. /// (`run_merge_config_pr`). The agent can never push `main` directly.
/// - **operator-team approval is required** to merge, and the author (not in /// - **operator-team approval is required** to merge, and the author (not in
/// the team) cannot self-approve. /// the team) cannot self-approve.
/// - **`enable_force_push` is a TRANSITIONAL allowance**: `push_config` still /// - **`enable_force_push` is `false`** — `main` only ever advances by
/// force-mirrors `applied → main` until the agent-opened PR-merge flow /// fast-forward. The merge handler's `ff_push_to_main` is already a
/// replaces it, and a protected branch otherwise rejects force-push. /// non-force push, so it lands fine. The legacy `push_config` mirror DOES
/// Forgejo's force-push allowlist is deploy-keys-only (no per-user list), so /// force-push (it re-points status tags and rewinds `main` on a failed-build
/// this is a plain `enable_force_push` toggle — only `core` is in the /// rollback), so the protection now rejects those non-ff updates — that
/// push-whitelist, so only `core` can force-push anyway. When `push_config` /// mirror runs best-effort until the agent-opened PR-merge flow retires it.
/// is retired, flip this to `false`; keep `core` in the push-whitelist so /// (Auto force-push is intentionally not allowed: per operator directive a
/// `ff_push_to_main` can still land fast-forwards. /// silent force-push is a bug, not a feature.)
/// ///
/// Idempotent: an existing rule for the branch (200/409/422) is success. /// Idempotent: an existing rule for the branch (200/409/422) is success.
async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> { async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> {
let url = format!("{FORGE_HTTP}/api/v1/repos/{CONFIG_ORG}/{repo}/branch_protections"); let url = format!("{FORGE_HTTP}/api/v1/repos/{CONFIG_ORG}/{repo}/branch_protections");
let body = format!( let body = format!(
r#"{{"branch_name":"main","enable_push_whitelist":true,"push_whitelist_usernames":["core"],"enable_merge_whitelist":true,"merge_whitelist_usernames":["core"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true,"allow_manual_merge":true,"enable_force_push":true}}"# r#"{{"branch_name":"main","enable_push_whitelist":true,"push_whitelist_usernames":["core"],"enable_merge_whitelist":true,"merge_whitelist_usernames":["core"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true,"allow_manual_merge":true,"enable_force_push":false}}"#
); );
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
match status.as_u16() { match status.as_u16() {