Watch
0
0
Fork
You've already forked hyperhive
0

config PRs: an operator's Forgejo merge deploys the merged rev

A config PR merged in the Forgejo UI changed nothing on the hive: the
hive's webhook ignores `closed`, its poll then cancels the dashboard
card, and `applied/main` stays where it was.

swarm-controller reads `merged`/`merge_commit_sha` off the
`pull_request` delivery it already receives for `agent-configs`, finds
the hive placing the agent by scanning every hive's wanted state (the
scan `declarations_elsewhere` already ran, factored out), and queues a
`TriggerDeploy` carrying the rev. Zero or several claimants deploy
nothing and log the claimants.

`DeployRequest` gains `rev: Option<String>` with `serde(default)`, so
rev-less payloads from either side keep decoding.

hive-c0re, given a rev for an agent it runs: a no-op when
`applied/main` already is the rev (a dashboard merge deploys its own
PR); otherwise it fetches the forge `main` with the core token,
requires the rev to descend from `applied/main` (the ancestry gate,
factored out of `run_deploy_merge_verify`), fast-forwards by CAS and
queues the usual relocking rebuild. No eval-verify on this path, per
mara (#4850 c90075). A refusal is commented on the PR that merged the
rev, found by commit.

swarm-controller's forge-objects pass converges every config repo's
`main` rule to merge whitelist `operators` + `core` and approval
whitelist `operators`. The hive's boot PATCH stops forcing
`enable_approvals_whitelist` off, so the two do not fight.

Refs #4850
This commit is contained in:
atlas 2026-10-02 18:45:18 +02:00
commit f1c695c212
11 changed files with 732 additions and 76 deletions

View file

@ -18,6 +18,10 @@
//! The low-latency path: a PR opening or closing shows up immediately
//! instead of waiting up to `POLL_INTERVAL`.
//!
//! [`merged`] reads the same delivery for a merge, which the webhook handler
//! turns into a deploy. The poll has no counterpart: it lists open PRs only,
//! so a merge whose delivery is lost deploys nothing.
//!
//! Per mara's review call: ship both from the start rather than the poll
//! alone — the eventual swarm-level replacement for `hive-c0re`'s own
//! poll+webhook pair needs both anyway, so building only half here would be
@ -36,6 +40,10 @@ use crate::forge::{Client, ConfigPrStatus};
/// whether it's still open.
#[derive(Deserialize)]
pub struct ConfigPrWebhookPayload {
/// `"closed"` on both a merge and a close without merging; `merged`
/// tells them apart.
#[serde(default)]
action: String,
pull_request: WebhookPullRequest,
repository: WebhookRepository,
}
@ -49,6 +57,43 @@ struct WebhookPullRequest {
/// doesn't matter to it.
state: String,
html_url: Option<String>,
#[serde(default)]
merged: bool,
/// The commit the merge left on the base branch.
#[serde(default)]
merge_commit_sha: Option<String>,
}
/// A config PR that was just merged: whose config, and the commit to deploy.
#[derive(Debug, PartialEq, Eq)]
pub struct MergedConfigPr {
pub agent: String,
pub rev: String,
}
/// The merge a verified `ConfigPr` delivery reports, if it reports one.
///
/// 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.
pub fn merged(body: &[u8]) -> Option<MergedConfigPr> {
let payload: ConfigPrWebhookPayload = serde_json::from_slice(body).ok()?;
if payload.action != "closed" || !payload.pull_request.merged {
return None;
}
let Some(rev) = payload.pull_request.merge_commit_sha else {
tracing::warn!(
agent = %payload.repository.name,
pr = payload.pull_request.number,
"config-pr webhook: merged PR carries no merge_commit_sha; nothing deployed"
);
return None;
};
Some(MergedConfigPr {
agent: payload.repository.name,
rev,
})
}
#[derive(Deserialize)]
@ -247,6 +292,51 @@ mod tests {
assert_eq!(snapshot["iris"].pr_number, 9);
}
fn closed(merged: bool) -> Vec<u8> {
serde_json::json!({
"action": "closed",
"pull_request": {
"number": 5,
"state": "closed",
"html_url": "https://forge.example/pr",
"merged": merged,
"merge_commit_sha": merged.then_some("abc123"),
},
"repository": { "name": "damocles" },
})
.to_string()
.into_bytes()
}
#[test]
fn a_merged_delivery_names_the_agent_and_the_merge_commit() {
assert_eq!(
super::merged(&closed(true)),
Some(super::MergedConfigPr {
agent: "damocles".to_owned(),
rev: "abc123".to_owned(),
})
);
}
#[test]
fn a_closed_unmerged_delivery_is_not_a_merge() {
assert_eq!(super::merged(&closed(false)), None);
}
#[test]
fn a_merged_pr_on_a_non_close_action_is_not_a_merge() {
let mut body: serde_json::Value =
serde_json::from_slice(&closed(true)).expect("fixture is json");
body["action"] = "label_updated".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);
}
#[test]
fn a_malformed_payload_leaves_the_cache_unchanged() {
let cache = ConfigPrCache::new();

View file

@ -1,8 +1,8 @@
//! The swarm-wide forge objects: the three seeded orgs (plus every mirror's
//! owner org), the `operators` merge-gate team in `agents` and
//! `agent-configs`, the operator-declared pull-mirrors, `internal/docs`,
//! `internal/knowledge` (public, README-seeded) and the `agent-configs` org
//! avatar.
//! `agent-configs`, the merge gate on every `agent-configs` repo's `main`, the
//! operator-declared pull-mirrors, `internal/docs`, `internal/knowledge`
//! (public, README-seeded) and the `agent-configs` org avatar.
//!
//! Every hive's `hive-c0re` used to ensure these in its boot sweep, but only
//! the one hive co-located with the forge container ever did (the sweep bails
@ -24,15 +24,16 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use base64::Engine as _;
use forgejo_api::structs::{
ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateOrgOption,
CreateTeamOption, CreateTeamOptionPermission, EditRepoOption, EditTeamOption,
EditTeamOptionPermission, MigrateRepoOptions, MigrateRepoOptionsService, Team, TeamPermission,
UpdateUserAvatarOption,
BranchProtection, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions,
CreateOrgOption, CreateTeamOption, CreateTeamOptionPermission, EditBranchProtectionOption,
EditRepoOption, EditTeamOption, EditTeamOptionPermission, MigrateRepoOptions,
MigrateRepoOptionsService, Team, TeamPermission, UpdateUserAvatarOption,
};
use forgejo_api::{ApiErrorKind, ForgejoError};
use reqwest::StatusCode;
use serde::Deserialize;
use super::legacy_tokens::CORE_USER;
use super::{
CONFIG_ORG, Client, KNOWLEDGE_ORG, KNOWLEDGE_REPO, OPERATORS_TEAM, base64_encode,
folds_into_success, is_ambiguous_validation_failure, is_confirmed_conflict,
@ -177,6 +178,8 @@ pub struct Desired {
avatar_png: Option<PathBuf>,
/// Where [`AVATAR_MARKER`] lives.
state_dir: PathBuf,
/// Converge every config repo's `main` rule on [`config_rule_edit`].
config_rules: bool,
}
impl Desired {
@ -211,6 +214,7 @@ impl Desired {
mirrors,
avatar_png,
state_dir,
config_rules: true,
}
}
@ -242,6 +246,7 @@ impl Desired {
mirrors: Vec::new(),
avatar_png: None,
state_dir: PathBuf::new(),
config_rules: false,
}
}
@ -319,6 +324,12 @@ struct Observed {
teams: BTreeMap<String, Seen<TeamState>>,
repos: BTreeMap<(String, String), Seen<RepoState>>,
avatar_set: bool,
/// Per config repo, whether its `main` rule already matches
/// [`config_rule_edit`]. `Absent` is a repo with no `main` rule.
config_rules: BTreeMap<String, Seen<bool>>,
/// The config org's repo list could not be read, so `config_rules` is
/// empty without meaning every rule matches.
config_repos_unread: bool,
}
impl Observed {
@ -368,6 +379,10 @@ enum Action {
org: String,
id: i64,
},
/// PATCH a config repo's `main` rule to [`config_rule_edit`].
ConvergeConfigRule {
repo: String,
},
CreateRepo {
owner: String,
name: String,
@ -409,7 +424,7 @@ impl Action {
| Self::SeedReadme { owner, .. }
| Self::CreateMirror { owner, .. }
| Self::SetMirrorInterval { owner, .. } => Some(owner),
Self::SetConfigOrgAvatar { .. } => Some(CONFIG_ORG),
Self::ConvergeConfigRule { .. } | Self::SetConfigOrgAvatar { .. } => Some(CONFIG_ORG),
}
}
}
@ -436,6 +451,12 @@ fn plan(desired: &Desired, observed: &Observed) -> Vec<Action> {
Seen::Present(_) | Seen::Unknown => {}
}
}
// After the teams: the rule names the config org's `operators` team.
for (repo, rule) in &observed.config_rules {
if *rule == Seen::Present(false) {
actions.push(Action::ConvergeConfigRule { repo: repo.clone() });
}
}
for r in &desired.repos {
let (owner, name) = (r.owner.to_owned(), r.name.to_owned());
let (create, set_public, seed) = match observed.repo(r.owner, r.name) {
@ -501,6 +522,50 @@ fn team_matches(t: &Team) -> bool {
&& t.description.as_deref() == Some(OPERATORS_TEAM_DESCRIPTION)
}
/// The merge gate on every config repo's `main`: the `operators` team approves
/// and merges, and `core` merges too, for the hive's dashboard approval.
///
/// Forgejo replaces each list this sends wholesale and keeps every field left
/// `None`, `required_approvals` included — the hive's own boot PATCH sets
/// that one.
fn config_rule_edit() -> EditBranchProtectionOption {
EditBranchProtectionOption {
apply_to_admins: None,
approvals_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
approvals_whitelist_username: None,
block_on_official_review_requests: None,
block_on_outdated_branch: None,
block_on_rejected_reviews: None,
dismiss_stale_approvals: None,
enable_approvals_whitelist: Some(true),
enable_merge_whitelist: Some(true),
enable_push: None,
enable_push_whitelist: None,
enable_status_check: None,
ignore_stale_approvals: None,
merge_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]),
merge_whitelist_usernames: Some(vec![CORE_USER.to_owned()]),
protected_file_patterns: None,
push_whitelist_deploy_keys: None,
push_whitelist_teams: None,
push_whitelist_usernames: None,
require_signed_commits: None,
required_approvals: None,
status_check_contexts: None,
unprotected_file_patterns: None,
}
}
/// Whether `rule` already has every field [`config_rule_edit`] sets.
fn config_rule_matches(rule: &BranchProtection) -> bool {
let only = |list: &Option<Vec<String>>, want: &str| matches!(list.as_deref(), Some([entry]) if entry == want);
rule.enable_merge_whitelist == Some(true)
&& only(&rule.merge_whitelist_teams, OPERATORS_TEAM)
&& only(&rule.merge_whitelist_usernames, CORE_USER)
&& rule.enable_approvals_whitelist == Some(true)
&& only(&rule.approvals_whitelist_teams, OPERATORS_TEAM)
}
/// Whether an error is Forgejo saying 404: the object is absent, as opposed
/// to a transport, auth or server failure.
fn is_not_found(e: &ForgejoError) -> bool {
@ -630,9 +695,34 @@ impl Client {
observed.repos.insert((owner, name), repo);
}
observed.avatar_set = desired.avatar_png.is_some() && desired.avatar_marker().exists();
if desired.config_rules && *observed.org(CONFIG_ORG) == Seen::Present(()) {
self.observe_config_rules(&mut observed).await;
}
observed
}
/// Read every config repo's `main` rule into `observed.config_rules`.
async fn observe_config_rules(&self, observed: &mut Observed) {
let repos = match self.api.org_list_repos(CONFIG_ORG).all().await {
Ok(repos) => repos,
Err(e) => {
tracing::warn!(error = %e, "swarm forge objects: listing {CONFIG_ORG} repos failed; skipping their branch rules this pass");
observed.config_repos_unread = true;
return;
}
};
for name in repos.into_iter().filter_map(|r| r.name) {
let rule = seen(
self.api
.repo_get_branch_protection(CONFIG_ORG, &name, "main")
.await,
&format!("branch protection of {CONFIG_ORG}/{name}"),
|rule| config_rule_matches(&rule),
);
observed.config_rules.insert(name, rule);
}
}
/// Execute `actions` in order. An action whose org failed to be created
/// this pass is skipped: it would fail anyway, and its org's failure is
/// the line worth reading.
@ -666,6 +756,14 @@ impl Client {
Action::CreateOrg { org } => self.create_org(org).await,
Action::CreateTeam { org } => self.create_operators_team(org).await,
Action::ReconcileTeam { org, id } => self.reconcile_operators_team(org, *id).await,
Action::ConvergeConfigRule { repo } => {
self.api
.repo_edit_branch_protection(CONFIG_ORG, repo, "main", config_rule_edit())
.await
.with_context(|| format!("converge {CONFIG_ORG}/{repo} main branch rule"))?;
tracing::info!(%repo, "swarm forge objects: config repo merge gate converged");
Ok(())
}
Action::CreateRepo {
owner,
name,
@ -939,7 +1037,13 @@ impl Client {
.repos
.values()
.filter(|s| **s == Seen::Unknown)
.count();
.count()
+ observed
.config_rules
.values()
.filter(|s| **s == Seen::Unknown)
.count()
+ usize::from(observed.config_repos_unread);
let actions = plan(desired, &observed);
let mut outcome = self.apply(desired, &actions).await;
outcome.failed += unknown;
@ -1184,6 +1288,60 @@ mod tests {
);
}
#[test]
fn only_a_config_rule_that_does_not_match_is_converged() {
let mut o = converged();
o.config_rules.insert(s("atlas"), Seen::Present(true));
o.config_rules.insert(s("damocles"), Seen::Present(false));
o.config_rules.insert(s("iris"), Seen::Absent);
o.config_rules.insert(s("argus"), Seen::Unknown);
assert_eq!(
plan(&desired(), &o),
vec![Action::ConvergeConfigRule {
repo: s("damocles")
}]
);
}
fn rule(merge_users: &[&str]) -> BranchProtection {
serde_json::from_value(serde_json::json!({
"enable_merge_whitelist": true,
"merge_whitelist_teams": [OPERATORS_TEAM],
"merge_whitelist_usernames": merge_users,
"enable_approvals_whitelist": true,
"approvals_whitelist_teams": [OPERATORS_TEAM],
}))
.expect("branch protection json")
}
#[test]
fn a_config_rule_matches_only_with_operators_and_core() {
assert!(config_rule_matches(&rule(&[CORE_USER])));
assert!(!config_rule_matches(&rule(&[])));
assert!(!config_rule_matches(&rule(&[CORE_USER, "mallory"])));
let mut core_only = rule(&[CORE_USER]);
core_only.merge_whitelist_teams = None;
assert!(!config_rule_matches(&core_only));
let mut approvals_off = rule(&[CORE_USER]);
approvals_off.enable_approvals_whitelist = Some(false);
assert!(!config_rule_matches(&approvals_off));
}
/// The edit is what the match checks for, so one PATCH converges a rule.
#[test]
fn the_config_rule_edit_produces_a_matching_rule() {
let edit = config_rule_edit();
let applied: BranchProtection = serde_json::from_value(serde_json::json!({
"enable_merge_whitelist": edit.enable_merge_whitelist,
"merge_whitelist_teams": edit.merge_whitelist_teams,
"merge_whitelist_usernames": edit.merge_whitelist_usernames,
"enable_approvals_whitelist": edit.enable_approvals_whitelist,
"approvals_whitelist_teams": edit.approvals_whitelist_teams,
}))
.expect("branch protection json");
assert!(config_rule_matches(&applied));
}
#[test]
fn mirror_owners_join_the_seeded_orgs_once() {
let d = Desired::new(

View file

@ -147,7 +147,13 @@ enum SwarmNodeKind {
/// `InitAgentConfigRepo`'s doc points at when it says the hive belongs
/// on the node that sends the deploy message. It names the subject the
/// message goes to, one per hive, so no other hive is woken by it.
TriggerDeploy { hive: String, agent: String },
TriggerDeploy {
hive: String,
agent: String,
/// The config commit to deploy, from a merged config PR. `None` from
/// agent creation, where the hive deploys what it seeds.
rev: Option<String>,
},
}
impl hive_jobq_wire::WireNode for SwarmNodeKind {
@ -190,8 +196,10 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
serde_json::json!({ "agent": agent })
}
SwarmNodeKind::MintHiveSenderToken { hive } => serde_json::json!({ "hive": hive }),
SwarmNodeKind::TriggerDeploy { hive, agent }
| SwarmNodeKind::SetAgentWanted { hive, agent } => {
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => {
serde_json::json!({ "agent": agent, "hive": hive, "rev": rev })
}
SwarmNodeKind::SetAgentWanted { hive, agent } => {
serde_json::json!({ "agent": agent, "hive": hive })
}
}
@ -355,12 +363,12 @@ async fn run_swarm_node(
),
Some(writer) => declare_new_agent(&writer, &hive, &agent).await,
},
SwarmNodeKind::TriggerDeploy { hive, agent } => match deps.queue {
SwarmNodeKind::TriggerDeploy { hive, agent, rev } => match deps.queue {
None => Outcome::Failed(
"no swarm queue is configured on this host, so no hive can be told to deploy"
.to_owned(),
),
Some(client) => publish_deploy(&client, &hive, &agent).await,
Some(client) => publish_deploy(&client, &hive, &agent, rev).await,
},
};
(builder, outcome)
@ -482,6 +490,7 @@ async fn publish_deploy(
client: &async_nats::Client,
hive: &str,
agent: &str,
rev: Option<String>,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
@ -489,6 +498,7 @@ async fn publish_deploy(
let subject = swarm_queue_client::deploy_subject(hive);
let request = swarm_queue_client::DeployRequest {
agent: agent.to_owned(),
rev,
};
let payload = match serde_json::to_vec(&request) {
Ok(payload) => payload,
@ -502,7 +512,7 @@ async fn publish_deploy(
"flushing the deploy event to {subject} failed: {e}"
));
}
tracing::info!(%subject, %hive, %agent, "swarm jobq: deploy requested");
tracing::info!(%subject, %hive, %agent, rev = ?request.rev, "swarm jobq: deploy requested");
Outcome::Done
}
@ -1644,27 +1654,103 @@ async fn declarations_elsewhere(
hive: &str,
) -> Result<Vec<(String, swarm_queue_client::wanted::HiveWanted)>, problem_details::ProblemDetails>
{
read_declarations(state, state.hives.iter().filter(|h| h.name != hive))
.await
.map_err(|(other, e)| {
let detail = format!(
"cannot tell whether {agent:?} already exists on hive {other:?}, so it was not \
placed: {e:#}"
);
error_problem(wanted_error_status(&e), &detail)
})
}
/// The published wanted state of each of `hives` that has one. No queue wired
/// up reads as nothing declared anywhere. The error names the hive whose read
/// failed.
async fn read_declarations<'a>(
state: &AppState,
hives: impl Iterator<Item = &'a HiveEntry>,
) -> Result<Vec<(String, swarm_queue_client::wanted::HiveWanted)>, (String, anyhow::Error)> {
let Some(writer) = state.wanted.as_deref() else {
return Ok(Vec::new());
};
let mut declared = Vec::new();
for other in state.hives.iter().filter(|h| h.name != hive) {
match writer.view(&other.name).await {
Ok(Some(declaration)) => declared.push((other.name.clone(), declaration)),
for hive in hives {
match writer.view(&hive.name).await {
Ok(Some(declaration)) => declared.push((hive.name.clone(), declaration)),
Ok(None) => {}
Err(e) => {
let detail = format!(
"cannot tell whether {agent:?} already exists on hive {:?}, so it was not \
placed: {e:#}",
other.name
);
return Err(error_problem(wanted_error_status(&e), &detail));
}
Err(e) => return Err((hive.name.clone(), e)),
}
}
Ok(declared)
}
/// Every hive whose declaration places `agent` there.
fn claiming_hives(
agent: &str,
declared: &[(String, swarm_queue_client::wanted::HiveWanted)],
) -> Vec<String> {
declared
.iter()
.filter(|(_, declaration)| {
declaration
.agents
.get(agent)
.is_some_and(|wanted| places_agent(wanted.state))
})
.map(|(hive, _)| hive.clone())
.collect()
}
/// Queue the deploy of a merged config PR on the one hive that runs its agent.
///
/// The hive is found by scanning every hive's wanted state, since nothing
/// records an agent's hive (see `forge::initial_agent_nix`). Zero claimants or
/// several deploy nothing: there is no hive to send it to, or no telling which.
async fn queue_merged_config_deploy(state: &AppState, merged: config_pr::MergedConfigPr) {
let config_pr::MergedConfigPr { agent, rev } = merged;
let declared = match read_declarations(state, state.hives.iter()).await {
Ok(declared) => declared,
Err((hive, e)) => {
tracing::warn!(
%agent, %rev, %hive, error = %format!("{e:#}"),
"config-pr merge: cannot read where the agent is placed; nothing deployed"
);
return;
}
};
let claimants = claiming_hives(&agent, &declared);
let [hive] = claimants.as_slice() else {
tracing::warn!(
%agent, %rev, ?claimants,
"config-pr merge: not exactly one hive places this agent; nothing deployed"
);
return;
};
let hive = hive.clone();
let inserted = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert_job(None, |b| {
vec![
b.node(SwarmNodeKind::TriggerDeploy {
hive: hive.clone(),
agent: agent.clone(),
rev: Some(rev.clone()),
})
.guid(),
]
});
match inserted {
Ok(_) => tracing::info!(%agent, %rev, %hive, "config-pr merge: deploy queued"),
Err(e) => {
tracing::warn!(%agent, %rev, %hive, error = %e, "config-pr merge: queueing the deploy failed");
}
}
}
/// [`queued_placements`] of the swarm's own queue, as it is now.
fn queued_placements_now(state: &AppState) -> Vec<(String, String)> {
queued_placements(
@ -1917,6 +2003,7 @@ fn declare_agent_job(
.node(SwarmNodeKind::TriggerDeploy {
hive: hive.to_owned(),
agent: agent.to_owned(),
rev: None,
})
.after_ok(init_config)
.after_any(mint_identity)
@ -3349,6 +3436,31 @@ mod tests {
assert!(super::placed_elsewhere("atlas", "pr1ma", &declared, &queued).is_empty());
}
#[test]
fn the_hive_placing_an_agent_claims_it_and_no_other_does() {
use swarm_queue_client::wanted::AgentState;
let declared = [
declaring("pr1ma", "atlas", AgentState::Paused),
declaring("sec0nd", "argus", AgentState::Up),
declaring("th1rd", "atlas", AgentState::Destroyed),
];
assert_eq!(super::claiming_hives("atlas", &declared), ["pr1ma"]);
assert!(super::claiming_hives("iris", &declared).is_empty());
}
#[test]
fn two_hives_placing_one_agent_both_claim_it() {
use swarm_queue_client::wanted::AgentState;
let declared = [
declaring("pr1ma", "atlas", AgentState::Up),
declaring("sec0nd", "atlas", AgentState::Up),
];
assert_eq!(
super::claiming_hives("atlas", &declared),
["pr1ma", "sec0nd"]
);
}
/// `refuse_placement` of `agent` on `pr1ma`, with `admin` reserved,
/// `roster` as the roster read, and `declared` as every hive's wanted
/// state, `pr1ma`'s included.

View file

@ -318,7 +318,8 @@ pub(super) fn verify(
/// delivery announces the change to every hive (see
/// [`announce_knowledge_change`]); a `ConfigPr` delivery updates
/// `crate::config_pr::ConfigPrCache` immediately (see that module's doc
/// comment for why both this and a periodic poll write the same cache).
/// comment for why both this and a periodic poll write the same cache), and a
/// merge queues a deploy of the merged commit on the agent's hive.
///
/// Returns 200 on an accepted delivery so Forgejo does not retry. A refused
/// one answers 401 (bad signature) or 503 (this daemon has no secret), and
@ -380,6 +381,9 @@ pub(super) async fn post_webhook_forge(
if let Some(cache) = state.config_prs.as_ref() {
cache.apply_webhook_delivery(&body);
}
if let Some(merged) = crate::config_pr::merged(&body) {
super::queue_merged_config_deploy(&state, merged).await;
}
}
DeliveryKind::VcsActivity => {
if let Some(activity) = crate::vcs_metrics::parse_push(&body) {