From 2b35ffd250b9d3800099da2cd21ad1b6560204d7 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 31 Aug 2026 10:55:40 +0200 Subject: [PATCH] swarm-controller: mark the instance webhook as a system webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /admin/hooks` reads `is_system_webhook` out of the config map and defaults it to false, which creates a forgejo *default* webhook — a template copied into repos created later — instead of a live instance-wide one. `GET /admin/hooks` returns only hooks with the flag set, so `list_hook_urls` could never see what the create had just made: every controller start listed zero hooks and created another default webhook (12 in 26h), while the instance-wide push observation the scope exists for never fired at all. Send the key on the `Instance` arm only, via a per-scope `extra_create_config()` so repo and org scopes stay unchanged. Closes #3807 --- swarm-controller/src/forge.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index ca4271a9..af87462a 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -624,8 +624,11 @@ impl Client { // arm's line reads exactly like a first, correct registration. // // The two ways to reach a create have different causes, so they are - // separated: `listed=0` is a permission or scope problem, a non-zero - // count with no match means the recorded url is not the one compared. + // separated: `listed=0` means the create is not landing in the set + // the list reads (a bucket or permission problem — see + // [`HookScope::extra_create_config`], which is where the instance + // scope's own version of that went wrong), a non-zero count with no + // match means the recorded url is not the one compared. match scope.list_hook_urls(&self.api).await { Ok(urls) => { if urls.iter().any(|u| u == target_url) { @@ -650,6 +653,9 @@ impl Client { let mut additional = BTreeMap::new(); additional.insert("secret".to_owned(), secret.to_owned()); + for (key, value) in scope.extra_create_config() { + additional.insert((*key).to_owned(), (*value).to_owned()); + } let hook = CreateHookOption { active: Some(true), authorization_header: None, @@ -692,11 +698,33 @@ enum HookScope<'a> { /// Forgejo's "global (system) webhook" — fires for every repo in the /// instance, in every org, present or future. The `admin_*` API /// namespace; needs the same `write:admin` scope - /// `Client::ensure_agent_user` already requires on this token. + /// `Client::ensure_agent_user` already requires on this token, **and** + /// the `is_system_webhook` config key from + /// [`Self::extra_create_config`] — that endpoint's default is the + /// *other* kind of admin hook. Instance, } impl HookScope<'_> { + /// Config-map keys the create call needs beyond the shared ones. + /// + /// Instance scope carries `is_system_webhook`, and it is load-bearing + /// twice over. `POST /admin/hooks` reads that key **out of the config + /// map** and defaults it to `false`, which makes a forgejo *default* + /// webhook — a template copied into repos created later, not a live + /// hook — while `GET /admin/hooks` returns only webhooks with the flag + /// set. Omitting it therefore breaks both halves at once: the hook is + /// not the instance-wide one this scope exists for, and + /// [`Self::list_hook_urls`] can never see it, so every process start + /// creates another one — twelve in a day, `listed=0` each time, + /// before this key was sent. + fn extra_create_config(&self) -> &'static [(&'static str, &'static str)] { + match self { + Self::Repo { .. } | Self::Org { .. } => &[], + Self::Instance => &[("is_system_webhook", "true")], + } + } + /// The `url` config value of every hook currently on this scope. async fn list_hook_urls(&self, api: &Forgejo) -> Result, ForgejoError> { let hooks = match self {