From 16ea3824b5eaa8e53c6fa33b2da80d6671ebbad7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 16:52:50 +0100 Subject: [PATCH] gitea: gate and judge every trunk the gate's source names, not main alone The operator, 2026-10-28: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, and later there may be more than main. The trunks are read at the call from mesh-delivery-gate's source, in one function (trunksOf), and fall back to main, said, only when it names none. A force-push onto any trunk is refused, and trunk_rule says whether the branch asked is a trunk. --- modules/gitea/module.json | 3 +- modules/gitea/protectfull.ts | 60 ++++++++++++++++++---- modules/gitea/test/protection-full.test.ts | 31 ++++++++++- modules/gitea/tools/index.ts | 18 ++++--- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 954a796b..e416637b 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -569,6 +569,7 @@ "mesh-build-node.keys", "mesh-build-image.keys", "mesh-delivery-checker.owed", - "mesh-delivery-indexer.modules" + "mesh-delivery-indexer.modules", + "mesh-delivery-gate.source" ] } diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index bd4c2272..e3c836b7 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -182,7 +182,12 @@ function controllerCovers(rule: string, branch: string): boolean { * cmd/mesh-controller/build_source.go does (ADR 0534 ยง6): the rule named for the branch, else the first glob rule that * covers it; it takes no push, its merge whitelist names mesh-tools and no agent-, and no administrator merges * past it. A mirror, not the judge: the controller's own verdict is the one that counts. */ -export function trunkRule(rules: BranchProtection[], branch: string): { holds: boolean; why: string } { +export function trunkRule(rules: BranchProtection[], branch: string, trunks: string[] = [MAIN_BRANCH]): { holds: boolean; why: string; is_trunk: boolean } { + const judged = judgedTrunkRule(rules, branch); + return { ...judged, is_trunk: trunks.includes(branch) }; +} + +function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: boolean; why: string } { const all = rules.map(asControllerRule); const r = all.find((x) => x.rule === branch) ?? all.find((x) => controllerCovers(x.rule, branch)); const no = (why: string) => ({ holds: false, why }); @@ -201,15 +206,52 @@ export function trunkRule(rules: BranchProtection[], branch: string): { holds: b return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` }; } -/** Why a call is the operator's, or null: it lets a force-push onto a rule covering main (the operator, 2026-10-28 - * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to main). No tool of - * this module can ask for that Approve yet, so such a call is refused with nothing sent. */ -export function forcePushToMain(rule: string, body: Record): string | null { - if (!ruleCovers(rule, MAIN_BRANCH)) return null; +/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */ +export type SourceRead = () => Promise>; + +/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each + * repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */ +export const TRUNK_SOURCE = "mesh-delivery-gate.source"; + +/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's + * source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and + * `from` says which. The one place trunks are read: several trunks later change only this. */ +export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> { + const full = `${owner}/${repo}`; + let src: Record; + try { + src = await read(); + } catch (err) { + return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` }; + } + const names = Object.keys(src).filter((k) => { + const heads = (src[k] as { heads?: Record } | null)?.heads; + return !!heads && typeof heads === "object" && full in heads; + }).sort(); + if (names.length) return { names, from: TRUNK_SOURCE }; + return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` }; +} + +/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */ +export function sourceFromBus(get: (name: string) => { keys(): Promise; get(k: string): Promise<{ value: unknown } | null> }): SourceRead { + return async () => { + const st = get(TRUNK_SOURCE); + const out: Record = {}; + for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null; + return out; + }; +} + +/** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28 + * 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). No + * tool of this module can ask for that Approve yet, so such a call is refused with nothing sent. */ +export function forcePushToTrunk(rule: string, body: Record, trunks: string[]): string | null { + const covered = trunks.filter((t) => ruleCovers(rule, t)); + if (!covered.length) return null; const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true || (["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0); if (!opens) return null; - return `the rule ${rule} covers ${MAIN_BRANCH}, and this call lets a force-push onto it: a force-push to main is the ` + - `operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's at ` + - `the forge. Nothing was sent`; + return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: a force-push to a trunk is ` + + `the operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's ` + + `at the forge. Nothing was sent`; } diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index 0184723e..f07e22e7 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -23,6 +23,7 @@ beforeEach(() => { enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0, enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }]; + source = {}; repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true }; sent = []; }); @@ -87,7 +88,11 @@ await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; const url = `http://127.0.0.1:${port}`; -const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); +// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories +// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel. +let source: Record }> = {}; +const readSource = async () => source; +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource); const setRepo = (args: Record) => tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args }); const setFull = (args: Record) => @@ -214,10 +219,32 @@ test("a force-push onto a rule covering main is refused, nothing sent; on anothe for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true }, { force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] }, { branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) { - await assert.rejects(setFull(args), /force-push to main is the operator's/, JSON.stringify(args)); + await assert.rejects(setFull(args), /force-push to a trunk is the operator's/, JSON.stringify(args)); } assert.deepEqual(sent, []); await setFull({ branch: "release/*", enable_force_push: true }); const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] }); assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer"); }); + +// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as +// the gate's source names them; "main" only when it names none, and said. +test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => { + source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } }; + await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/); + await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/); + assert.deepEqual(sent, []); + const main: any = await setFull({ enable_force_push: true }); + assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" }); + assert.equal(main.trunk_rule.is_trunk, false); + const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true }); + assert.equal(beta.trunk_rule.is_trunk, true); + assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why); +}); + +test("a source that names no trunk of the repository falls back to main, and says so", async () => { + const out: any = await setFull({ block_admin_merge_override: true }); + assert.equal(out.trunks.names[0], "main"); + assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/); + assert.equal(out.trunk_rule.is_trunk, true); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 27c80f60..e1029d65 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,7 +12,8 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools" import { emit } from "@novox/mesh-sdk/events"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; import { readTeamRights } from "../teamrights.js"; -import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToMain, fullProtectionBody, givenFields, trunkRule, unapplied } from "../protectfull.js"; +import { state } from "@novox/mesh-sdk/state"; +import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -89,13 +90,13 @@ function listedDirs(raw: unknown): string[] | undefined { return raw as string[]; } -export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { - return namesChecked(allGiteaTools(gitea, admin)); +export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { + return namesChecked(allGiteaTools(gitea, admin, source)); } // gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one // writer (novox/hq issue 456, ADR 0323). -function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { +function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] { return [ // ---- Repositories ---- { @@ -500,8 +501,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini "enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " + "flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " + "forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " + - "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering main is refused, nothing sent, until the module can ask the operator's Approve for it. As the forge's " + - "administrator. Answers the rule before and after, and trunk_rule: whether the controller now registers a module from " + + "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " + + "administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " + "this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " + "agent-, no administrator override) and why not.", input: { @@ -522,7 +523,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini const given = givenFields(args); if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`); const body = fullProtectionBody(args, given); - const operators = forcePushToMain(branch, body); + const trunks = await trunksOf(owner, repo, source); + const operators = forcePushToTrunk(branch, body, trunks.names); if (operators) throw new Refused(operators); const forge = admin ? admin() : gitea; const rules = await forge.branchProtections(owner, repo); @@ -540,7 +542,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body), before: before ? { ...summarised(before), fields: before } : null, after: { ...summarised(read), fields: read }, - trunk_rule: trunkRule(after, branch), + trunks, trunk_rule: trunkRule(after, branch, trunks.names), }; }, },