fix(#2393): ff-merge config prs via forge api instead of pushing main

This commit is contained in:
damocles 2026-07-13 11:18:43 +02:00
commit 6e0f6893cf
4 changed files with 135 additions and 197 deletions

View file

@ -11,8 +11,8 @@ use anyhow::{Context, Result};
use forgejo_api::structs::{
AddCollaboratorOption, AddCollaboratorOptionPermission, CreateBranchProtectionOption,
CreateOrgOption, CreateRepoOption, CreateTeamOption, CreateTeamOptionPermission,
EditRepoOption, EditTeamOption, EditTeamOptionPermission, MigrateRepoOptions,
MigrateRepoOptionsService, Repository,
EditBranchProtectionOption, EditRepoOption, EditTeamOption, EditTeamOptionPermission,
MigrateRepoOptions, MigrateRepoOptionsService, Repository,
};
use forgejo_api::{ApiErrorKind, ForgejoError};
use reqwest::StatusCode;
@ -795,36 +795,35 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()>
/// Apply branch protection to an `agent-configs/<name>` repo's `main` so it
/// can serve as the agent-editable, PR-merge config surface:
/// - **push + merge whitelists are `core`-only** — the agent (a write
/// collaborator) can push feature branches and open config PRs, but only
/// hive-c0re lands on `main`, via its verify-and-ff-push merge handler
/// (`run_merge_config_pr`). The agent can never push `main` directly.
/// - **operator-team approval is required** to merge, and the author (not in
/// the team) cannot self-approve.
/// - **force-pushing `main` stays impossible** — `main` only ever advances by
/// fast-forward. The merge handler's `ff_push_to_main` is already a
/// non-force push, so it lands fine. The legacy `push_config` mirror is
/// also ff-only (non-fast-forward is caught and silently skipped).
/// (Auto force-push is intentionally not allowed: per operator directive a
/// silent force-push is a bug, not a feature. The raw-HTTP predecessor
/// sent `"enable_force_push":false` + `"allow_manual_merge":true` in this
/// body; neither is a `CreateBranchProtectionOption` field, so Forgejo
/// ignored both keys — dropping them changes nothing: force-push
/// protection defaults to off, and `allow_manual_merge` is a *repo*
/// setting, not a branch-protection one.)
/// - **`main` is never directly pushable** — no push is enabled on the
/// protected branch, so neither the agent (a write collaborator) nor
/// hive-c0re can `git push` it. It only advances via the config-PR merge
/// handler (`run_merge_config_pr`), which fast-forward-*merges* the reviewed
/// head through the forge merge API (`Do=fast-forward-only`,
/// `head_commit_id` pinned to the reviewed sha).
/// - **merge is whitelisted to `core`** — only hive-c0re can merge a config PR;
/// the agent can push feature branches + open PRs but can't land them.
/// - **the operator's dashboard approval is the gate** — approval happens on
/// the `MergeConfigPr` card and hive-c0re only merges an approved PR. There's
/// deliberately no Forgejo `required_approvals` review requirement: the flow
/// never does an in-forge review, so requiring one would only dead-block the
/// `core` merge. The dashboard approval + the `core`-only merge whitelist are
/// the real gate.
/// - **fast-forward-only** — `main` only ever advances by fast-forward; a raced
/// non-ff `main` is refused by the merge API rather than force-moved.
///
/// Idempotent: an existing rule for the branch (200/409/422) is success.
async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> {
let client = api(token)?;
let mut rule = main_branch_protection_option();
rule.enable_push_whitelist = Some(true);
rule.push_whitelist_usernames = Some(vec!["core".to_owned()]);
// Only `core` may merge (through the config-PR merge API); no push is
// enabled at all, so `main` can only advance via that merge. Push +
// approval defaults are off in `main_branch_protection_option`, so a fresh
// rule needs nothing but the merge whitelist. (`config_repo_protection_edit`
// must clear the old push/approval fields explicitly, since a PATCH leaves
// unset fields untouched.)
rule.enable_merge_whitelist = Some(true);
rule.merge_whitelist_usernames = Some(vec!["core".to_owned()]);
rule.enable_approvals_whitelist = Some(true);
rule.approvals_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
rule.required_approvals = Some(1);
rule.block_on_official_review_requests = Some(true);
let Err(create_err) = client
.repo_create_branch_protection(CONFIG_ORG, repo, rule)
.await
@ -832,33 +831,73 @@ async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<
tracing::info!(%repo, "forge: applied config-repo branch protection");
return Ok(());
};
// A create failure is ambiguous: it can mean "rule already exists"
// (idempotent success) OR a silent rejection — e.g. a 422 where Forgejo
// refused the request and created NO rule. The old code treated
// 200/409/422 all as success, so a rejected POST left the repo
// unprotected with no error (the reported case: a new agent's config
// repo had no `main` rule and nothing was logged). Don't trust the
// status: verify the `main` rule actually exists, and on failure
// surface the create error (its Display carries Forgejo's validation
// message) so the real reason is in the journal.
// Create failed. This is ambiguous — "rule already exists" (the common
// idempotent case) OR a silent rejection that created no rule. Either way a
// create never *updates* an existing rule, and repos protected under an
// older shape (the push-based / approval-gated rules) carry stale settings.
// So converge the existing rule with a PATCH that explicitly clears them:
// it fixes those stale repos on the next boot's `ensure_config_repo` pass,
// is a harmless no-op when the rule is already correct, and still fails
// loudly when no rule can be established (you can't edit a rule that isn't
// there) — so it can't leave a repo silently unprotected.
match client
.repo_get_branch_protection(CONFIG_ORG, repo, "main")
.repo_edit_branch_protection(CONFIG_ORG, repo, "main", config_repo_protection_edit())
.await
{
Ok(_) => {
// Debug, not info: this PATCH runs on every `ensure_config_repo`
// boot pass for every existing repo (create → 409 → converge), so
// it's almost always a no-op re-assertion — info-logging it would
// be N lines of noise per boot on a many-agent hive.
tracing::debug!(
%repo, create_error = %create_err,
"forge: config-repo branch protection already present"
"forge: converged existing config-repo branch protection"
);
Ok(())
}
Err(check_err) => anyhow::bail!(
Err(edit_err) => anyhow::bail!(
"branch protection for {CONFIG_ORG}/{repo} not applied: create failed \
({create_err}); GET main rule failed ({check_err}), no `main` rule present"
({create_err}); edit of existing `main` rule failed ({edit_err})"
),
}
}
/// The `EditBranchProtectionOption` that converges an existing
/// `agent-configs/<name>` `main` rule to the current desired shape: merge
/// whitelisted to `core`, **no direct push at all**, and **no in-forge approval
/// requirement** (the dashboard approval + `core`-only merge whitelist are the
/// gate). The push/approval fields are set to their explicit off-values, not
/// left `None`, so a repo carrying an older push-based or approval-gated rule is
/// actually *converged* rather than merely re-asserted — a PATCH leaves unset
/// fields untouched. Every field unrelated to this policy stays `None`.
fn config_repo_protection_edit() -> EditBranchProtectionOption {
EditBranchProtectionOption {
apply_to_admins: None,
approvals_whitelist_teams: None,
approvals_whitelist_username: None,
block_on_official_review_requests: Some(false),
block_on_outdated_branch: None,
block_on_rejected_reviews: None,
dismiss_stale_approvals: None,
enable_approvals_whitelist: Some(false),
enable_merge_whitelist: Some(true),
enable_push: Some(false),
enable_push_whitelist: Some(false),
enable_status_check: None,
ignore_stale_approvals: None,
merge_whitelist_teams: None,
merge_whitelist_usernames: Some(vec!["core".to_owned()]),
protected_file_patterns: None,
push_whitelist_deploy_keys: None,
push_whitelist_teams: None,
push_whitelist_usernames: Some(Vec::new()),
require_signed_commits: None,
required_approvals: Some(0),
status_check_contexts: None,
unprotected_file_patterns: None,
}
}
/// Create a repo for `agent` in the c0re-owned [`AGENTS_ORG`] and wire the
/// perms: the org owns it (perms stay c0re-managed), the agent is added
/// as a **write** collaborator (not owner — can push + open PRs but can't