fix(#2393): ff-merge config prs via forge api instead of pushing main
This commit is contained in:
parent
ef14641b94
commit
6e0f6893cf
4 changed files with 135 additions and 197 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue