config PRs: document the operator merge, deploy only merges into main
docs: the config-repo `main` merge gate (merge = `core` + team `operators`, approvals = `operators`), the operator merge in the forge UI and what it deploys, the hand-added `operators` membership, and that with no eval-verify a failed rebuild leaves `applied/main` at the merged commit. The swarm README lists the converged gate and the merge deploy. `merged()` also requires `pull_request.base.ref == "main"`: the hive deploys its config repo's `main`, so a merge into another branch would only cost a forge fetch and a refusal comment (argus, #4894). Refs #4850
This commit is contained in:
parent
f1c695c212
commit
a5ea015bc6
4 changed files with 90 additions and 16 deletions
|
|
@ -62,9 +62,10 @@ request.
|
||||||
config, not the tree to edit: authoring in place there produces no PR
|
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
|
and no approval. (it's currently mounted read-write, which is a
|
||||||
defect tracked separately, not an authoring path.)
|
defect tracked separately, not an authoring path.)
|
||||||
Branch protection (push/merge allowlist = `core`, approvals allowlist
|
Branch protection (the agent isn't on the `main` push allowlist;
|
||||||
= operator team; see "Forge mirror" below) makes the agent a
|
merge allowlist = `core` user + `operators` team; approvals allowlist
|
||||||
write collaborator that **can't merge its own config PR**.
|
= `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
|
2. hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo
|
||||||
`pull_request` event (opened / synchronized / reopened) and queues a
|
`pull_request` event (opened / synchronized / reopened) and queues a
|
||||||
`MergeConfigPr` approval; a poll fallback catches any missed webhook.
|
`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
|
canonical sha and the terminal tag (the approval row carries a
|
||||||
`submitter` column recording the agent the change is for).
|
`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
|
### Withdrawing a pending approval
|
||||||
|
|
||||||
The submitting agent can call `cancel_loose_end(kind: "approval", id)` to
|
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/<name>`
|
Each agent is a **write collaborator on its own** `agent-configs/<name>`
|
||||||
repo — so it can push a branch and open a config PR — but not a member
|
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
|
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
|
the forge. Branch protection keeps the agent off the `main` push
|
||||||
operator-team approval, so an agent can't fast-forward its own config or
|
allowlist and whitelists merging to the `core` user and the `operators`
|
||||||
self-merge its PR (see the End-to-end flow above). hive-c0re passes the tokenised push
|
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
|
URL inline to `git push`, never writing it into
|
||||||
`applied/<n>/.git/config`; that repo is RO-bind-mounted into the root
|
`applied/<n>/.git/config`; that repo is RO-bind-mounted into the root
|
||||||
agent, and a stored token would leak core's admin credential to an
|
agent, and a stored token would leak core's admin credential to an
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,15 @@ Two things live in the `agent-configs` Forgejo organization:
|
||||||
webhook at `/webhook/config-pr` queues a `MergeConfigPr` approval;
|
webhook at `/webhook/config-pr` queues a `MergeConfigPr` approval;
|
||||||
`hive-c0re/src/forge/config_pr_poll.rs` re-scans every 5 minutes as a
|
`hive-c0re/src/forge/config_pr_poll.rs` re-scans every 5 minutes as a
|
||||||
fault-tolerance backstop) — but
|
fault-tolerance backstop) — but
|
||||||
`main` is branch-protected core-only: only hive-c0re's verify-and-ff-push
|
`main` is branch-protected: the merge whitelist is the `core` user
|
||||||
merge handler lands on `main`, the operator team must approve first, and
|
(hive-c0re's merge of an approved `MergeConfigPr`) and the `operators`
|
||||||
the agent can neither push `main` directly nor self-merge. `main` is
|
team (an operator merging in the Forgejo UI, which deploys the merged
|
||||||
fast-forward-only — hive-c0re never force-pushes (the merge handler's ff
|
commit — see
|
||||||
push lands fine; the `push_config` mirror pushes `main` + the add-only
|
[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
|
status tags without force, and treats a non-fast-forward rejection of
|
||||||
`main` after a rolled-back deploy as expected — the forge keeps the
|
`main` after a rolled-back deploy as expected — the forge keeps the
|
||||||
approved history, the `failed/<id>` tag records the divergence).
|
approved history, the `failed/<id>` tag records the divergence).
|
||||||
|
|
|
||||||
|
|
@ -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
|
- the orgs `agent-configs`, `internal` and `agents`, plus each mirror's
|
||||||
owner org;
|
owner org;
|
||||||
- the empty `operators` merge-gate team in `agents` and `agent-configs`;
|
- 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
|
- the pull-mirrors from `deploy.forgejo.mirrors` on the controller's
|
||||||
host (with the `actions/checkout` one `deploy.forgejo.ci.enable` adds);
|
host (with the `actions/checkout` one `deploy.forgejo.ci.enable` adds);
|
||||||
- `internal/docs` (private) and `internal/knowledge` (public, with a
|
- `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
|
re-derive. Approval happens once, at the swarm level: a hive receives a
|
||||||
decision, not an event to adjudicate.
|
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
|
**`internal/knowledge` is on that path.** The controller's is the only
|
||||||
hook on it ([`knowledge.md`](../integrations/knowledge.md) covers clearing a
|
hook on it ([`knowledge.md`](../integrations/knowledge.md) covers clearing a
|
||||||
leftover). A webhook has exactly one target URL, so a second registration
|
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
|
**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
|
`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
|
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
|
a hive's stops it acting on config PRs; removing the controller's stops
|
||||||
gets recreated on its next start.
|
forge-UI merges from deploying until its next start recreates it.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,16 @@ struct WebhookPullRequest {
|
||||||
/// The commit the merge left on the base branch.
|
/// The commit the merge left on the base branch.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
merge_commit_sha: Option<String>,
|
merge_commit_sha: Option<String>,
|
||||||
|
/// The branch the PR targets. Only a merge into `main` is deployed: the
|
||||||
|
/// hive builds its config repo's `main`.
|
||||||
|
#[serde(default)]
|
||||||
|
base: Option<WebhookBranch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WebhookBranch {
|
||||||
|
#[serde(rename = "ref")]
|
||||||
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A config PR that was just merged: whose config, and the commit to deploy.
|
/// 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
|
/// 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
|
/// 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`;
|
/// must not deploy it again. So does only a merge into `main`. A body that
|
||||||
/// [`ConfigPrCache::apply_webhook_delivery`] already logs it.
|
/// does not parse is `None`; [`ConfigPrCache::apply_webhook_delivery`]
|
||||||
|
/// already logs it.
|
||||||
pub fn merged(body: &[u8]) -> Option<MergedConfigPr> {
|
pub fn merged(body: &[u8]) -> Option<MergedConfigPr> {
|
||||||
let payload: ConfigPrWebhookPayload = serde_json::from_slice(body).ok()?;
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let Some(rev) = payload.pull_request.merge_commit_sha else {
|
let Some(rev) = payload.pull_request.merge_commit_sha else {
|
||||||
|
|
@ -301,6 +317,7 @@ mod tests {
|
||||||
"html_url": "https://forge.example/pr",
|
"html_url": "https://forge.example/pr",
|
||||||
"merged": merged,
|
"merged": merged,
|
||||||
"merge_commit_sha": merged.then_some("abc123"),
|
"merge_commit_sha": merged.then_some("abc123"),
|
||||||
|
"base": { "ref": "main" },
|
||||||
},
|
},
|
||||||
"repository": { "name": "damocles" },
|
"repository": { "name": "damocles" },
|
||||||
})
|
})
|
||||||
|
|
@ -332,6 +349,14 @@ mod tests {
|
||||||
assert_eq!(super::merged(body.to_string().as_bytes()), None);
|
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]
|
#[test]
|
||||||
fn an_open_delivery_is_not_a_merge() {
|
fn an_open_delivery_is_not_a_merge() {
|
||||||
assert_eq!(super::merged(&payload("damocles", 5, "open")), None);
|
assert_eq!(super::merged(&payload("damocles", 5, "open")), None);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue