fix(#2174): create operators team in agent-configs org too so config-repo branch protection applies

This commit is contained in:
damocles 2026-07-03 22:26:24 +02:00
commit 5d597d92ea

View file

@ -1059,29 +1059,36 @@ pub fn is_hive_managed_namespace(ns: &str) -> bool {
HIVE_MANAGED_NAMESPACES.contains(&ns)
}
/// Provision the [`OPERATORS_TEAM`] inside [`AGENTS_ORG`] as an **empty**
/// team. Branch protection on agents-org repos references it as the
/// Provision the [`OPERATORS_TEAM`] inside `org` as an **empty** team.
/// Branch protection on that org's repos references it as the
/// merge/approval whitelist; the operator adds herself as a member via the
/// forge UI / hivectl. `includes_all_repositories` so the gate applies to
/// every agent repo; `write` is enough to approve + merge. hive-c0re never
/// manages membership. Idempotent (422/409 = already exists).
async fn ensure_operators_team(token: &str) -> Result<()> {
let url = format!("{FORGE_HTTP}/api/v1/orgs/{AGENTS_ORG}/teams");
/// every repo in the org; `write` is enough to approve + merge. hive-c0re
/// never manages membership. Idempotent (422/409 = already exists).
///
/// Must run for BOTH [`AGENTS_ORG`] and [`CONFIG_ORG`]: Gitea teams are
/// org-scoped, so a config-repo branch-protection rule referencing
/// `operators` needs the team to exist in `agent-configs` too. Missing it
/// there 422'd every `apply_config_repo_branch_protection`, leaving config
/// repos unprotected — operator-merged config PRs then bypassed the deploy
/// pipeline and silently didn't apply.
async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
let url = format!("{FORGE_HTTP}/api/v1/orgs/{org}/teams");
let body = format!(
r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"#
);
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
match status.as_u16() {
201 => {
tracing::info!("forge: created {OPERATORS_TEAM} team in {AGENTS_ORG}");
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
Ok(())
}
409 | 422 => {
tracing::debug!("forge: {OPERATORS_TEAM} team already exists");
tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists");
Ok(())
}
other => {
anyhow::bail!("POST /orgs/{AGENTS_ORG}/teams ({OPERATORS_TEAM}) returned HTTP {other}")
anyhow::bail!("POST /orgs/{org}/teams ({OPERATORS_TEAM}) returned HTTP {other}")
}
}
}
@ -1282,11 +1289,17 @@ pub async fn ensure_all() {
// `HYPERHIVE_FORGE_MIRRORS` env). Each ensures its own dest org, so
// this is independent of the SEEDED_ORGS loop above.
ensure_mirrors(token).await;
// Provision the operator merge-gate team (empty) inside the agents
// org so branch protection can reference it before anyone joins
//. The operator adds herself as a member out-of-band.
if let Err(e) = ensure_operators_team(token).await {
tracing::warn!(error = ?e, "forge: ensure_operators_team failed");
// Provision the operator merge-gate team (empty) inside BOTH the
// agents org and the agent-configs org so branch protection in each
// can reference it before anyone joins. Gitea teams are org-scoped —
// missing the agent-configs copy 422'd every config-repo protection
// apply, leaving those repos unprotected and letting operator-merged
// config PRs bypass the deploy pipeline. The operator adds herself as
// a member out-of-band.
for org in [AGENTS_ORG, CONFIG_ORG] {
if let Err(e) = ensure_operators_team(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_operators_team failed");
}
}
// Meta repo lives at core/meta — pushed from git_commit in
// meta.rs on every deploy/lock-update. Make sure it exists