diff --git a/docs/agent-lifecycle/approvals.md b/docs/agent-lifecycle/approvals.md index c211b587..bec3e346 100644 --- a/docs/agent-lifecycle/approvals.md +++ b/docs/agent-lifecycle/approvals.md @@ -62,9 +62,10 @@ request. config, not the tree to edit: authoring in place there produces no PR and no approval. (it's currently mounted read-write, which is a defect tracked separately, not an authoring path.) - Branch protection (push/merge allowlist = `core`, approvals allowlist - = operator team; see "Forge mirror" below) makes the agent a - write collaborator that **can't merge its own config PR**. + Branch protection (the agent isn't on the `main` push allowlist; + merge allowlist = `core` user + `operators` team; approvals allowlist + = `operators` team; see "Forge mirror" below) makes the agent a write + collaborator that **can't merge its own config PR**. 2. hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo `pull_request` event (opened / synchronized / reopened) and queues a `MergeConfigPr` approval; a poll fallback catches any missed webhook. @@ -109,6 +110,39 @@ request. canonical sha and the terminal tag (the approval row carries a `submitter` column recording the agent the change is for). +### Operator merge in the forge UI + +An operator can also merge a config PR straight in the Forgejo UI. That +deploys the merged commit on the hive that runs the agent: + +1. swarm-controller gets the `agent-configs` org's `pull_request` + delivery. A `closed` event with `merged: true` and base branch `main` + names the commit in `merge_commit_sha`. +2. The controller looks up which hive's wanted state places the agent + and publishes a deploy request carrying that commit on that hive's + deploy subject. With no such hive, or more than one, it deploys + nothing and logs a warning naming them. +3. If the hive's `applied/main` already is that commit — a dashboard + approval merges and deploys its own PR — it does nothing. Otherwise + it fetches the config repo's `main`, requires the commit to descend + from `applied/main`, fast-forwards `applied/main` to it, and queues a + rebuild. A failure before the rebuild (the fetch, or a commit that + doesn't descend) deploys nothing and posts the error as a comment on + the merged PR. An agent with no container on that hive yet ignores + the commit; its first deploy builds what it seeds. + +This path runs **no eval-verify**. A merged config that doesn't +evaluate or build fails the rebuild, and `applied/main` stays at that +commit, so the agent's rebuilds keep failing until a fix merges. A +failed rebuild shows on the hive like any other and isn't commented on +the PR. + +Merging needs membership of the `operators` team in `agent-configs`. +The team starts empty: add each operator by hand in the forge UI. A +merge whose webhook delivery never reaches the controller deploys +nothing. The hive's own poll cancels the dashboard card for that PR +with the note `PR merged/closed outside the approval`. + ### Withdrawing a pending approval The submitting agent can call `cancel_loose_end(kind: "approval", id)` to @@ -450,9 +484,12 @@ forge never blocks a deploy. Each agent is a **write collaborator on its own** `agent-configs/` repo — so it can push a branch and open a config PR — but not a member of any other agent's, so it can't reach another agent's config through -the forge. Branch protection keeps `main` push/merge `core`-only with -operator-team approval, so an agent can't fast-forward its own config or -self-merge its PR (see the End-to-end flow above). hive-c0re passes the tokenised push +the forge. Branch protection keeps the agent off the `main` push +allowlist and whitelists merging to the `core` user and the `operators` +team, so an agent can't fast-forward its own config or self-merge its +PR (see the End-to-end flow and +[Operator merge in the forge UI](#operator-merge-in-the-forge-ui) above). +hive-c0re passes the tokenised push URL inline to `git push`, never writing it into `applied//.git/config`; that repo is RO-bind-mounted into the root agent, and a stored token would leak core's admin credential to an diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index a626c4ad..21e38fd8 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -71,11 +71,15 @@ Two things live in the `agent-configs` Forgejo organization: webhook at `/webhook/config-pr` queues a `MergeConfigPr` approval; `hive-c0re/src/forge/config_pr_poll.rs` re-scans every 5 minutes as a fault-tolerance backstop) — but - `main` is branch-protected core-only: only hive-c0re's verify-and-ff-push - merge handler lands on `main`, the operator team must approve first, and - the agent can neither push `main` directly nor self-merge. `main` is - 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 + `main` is branch-protected: the merge whitelist is the `core` user + (hive-c0re's merge of an approved `MergeConfigPr`) and the `operators` + team (an operator merging in the Forgejo UI, which deploys the merged + commit — see + [approvals.md § Operator merge in the forge UI](../agent-lifecycle/approvals.md#operator-merge-in-the-forge-ui)), + the approval whitelist is the `operators` team, and the agent can neither + push `main` directly nor self-merge. hive-c0re's own merge is + fast-forward-only, and hive-c0re never force-pushes (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). diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 455cd5bb..ad22eec1 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -227,6 +227,9 @@ one per hive. It ensures them at start and every five minutes after - the orgs `agent-configs`, `internal` and `agents`, plus each mirror's owner org; - the empty `operators` merge-gate team in `agents` and `agent-configs`; +- the `main` merge gate on every `agent-configs` repo: merge whitelist = + the `operators` team and the `core` user, approval whitelist = the + `operators` team. The controller leaves a repo with no `main` rule alone; - the pull-mirrors from `deploy.forgejo.mirrors` on the controller's host (with the `actions/checkout` one `deploy.forgejo.ci.enable` adds); - `internal/docs` (private) and `internal/knowledge` (public, with a @@ -252,6 +255,11 @@ message — _the knowledge repo changed_, _deploy agent `foo` at rev re-derive. Approval happens once, at the swarm level: a hive receives a decision, not an event to adjudicate. +A `config-pr` delivery reporting a PR merged into `main` queues a deploy +of its `merge_commit_sha` on the one hive whose wanted state places the +agent; with no such hive, or several, the controller deploys nothing. See +[approvals.md § Operator merge in the forge UI](../agent-lifecycle/approvals.md#operator-merge-in-the-forge-ui). + **`internal/knowledge` is on that path.** The controller's is the only hook on it ([`knowledge.md`](../integrations/knowledge.md) covers clearing a leftover). A webhook has exactly one target URL, so a second registration @@ -262,8 +270,8 @@ would take delivery away from the first rather than add a recipient. **The `agent-configs` org isn't.** Each hive registers its own `pull_request` hook there, so that repo has two — the hive's and the controller's — and **both are expected; don't delete either.** Removing -a hive's stops it acting on config PRs; removing the controller's just -gets recreated on its next start. +a hive's stops it acting on config PRs; removing the controller's stops +forge-UI merges from deploying until its next start recreates it. diff --git a/swarm-controller/src/config_pr.rs b/swarm-controller/src/config_pr.rs index 80416259..2f466e6d 100644 --- a/swarm-controller/src/config_pr.rs +++ b/swarm-controller/src/config_pr.rs @@ -62,6 +62,16 @@ struct WebhookPullRequest { /// The commit the merge left on the base branch. #[serde(default)] merge_commit_sha: Option, + /// The branch the PR targets. Only a merge into `main` is deployed: the + /// hive builds its config repo's `main`. + #[serde(default)] + base: Option, +} + +#[derive(Deserialize)] +struct WebhookBranch { + #[serde(rename = "ref")] + name: String, } /// A config PR that was just merged: whose config, and the commit to deploy. @@ -75,11 +85,17 @@ pub struct MergedConfigPr { /// /// Only the `closed` action counts: Forgejo also sends `merged: true` on /// later events about an already-merged PR (a label or an edit), and those -/// must not deploy it again. A body that does not parse is `None`; -/// [`ConfigPrCache::apply_webhook_delivery`] already logs it. +/// must not deploy it again. So does only a merge into `main`. A body that +/// does not parse is `None`; [`ConfigPrCache::apply_webhook_delivery`] +/// already logs it. pub fn merged(body: &[u8]) -> Option { let payload: ConfigPrWebhookPayload = serde_json::from_slice(body).ok()?; - if payload.action != "closed" || !payload.pull_request.merged { + let into_main = payload + .pull_request + .base + .as_ref() + .is_some_and(|base| base.name == "main"); + if payload.action != "closed" || !payload.pull_request.merged || !into_main { return None; } let Some(rev) = payload.pull_request.merge_commit_sha else { @@ -301,6 +317,7 @@ mod tests { "html_url": "https://forge.example/pr", "merged": merged, "merge_commit_sha": merged.then_some("abc123"), + "base": { "ref": "main" }, }, "repository": { "name": "damocles" }, }) @@ -332,6 +349,14 @@ mod tests { assert_eq!(super::merged(body.to_string().as_bytes()), None); } + #[test] + fn a_merge_into_another_branch_is_not_deployed() { + let mut body: serde_json::Value = + serde_json::from_slice(&closed(true)).expect("fixture is json"); + body["pull_request"]["base"]["ref"] = "staging".into(); + assert_eq!(super::merged(body.to_string().as_bytes()), None); + } + #[test] fn an_open_delivery_is_not_a_merge() { assert_eq!(super::merged(&payload("damocles", 5, "open")), None);