gitea: gate and judge every trunk the gate's source names, not main alone
mesh/delivery merged: source.main moved to novox/mesh-catalog@3feeefc70f7c
mesh/check checking 16ea3824b5ea on 3feeefc70f7c

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.
This commit is contained in:
2026-10-28 16:52:50 +01:00
parent fabb62da00
commit 16ea3824b5
4 changed files with 92 additions and 20 deletions
+2 -1
View File
@@ -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"
]
}
+51 -9
View File
@@ -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-<node>, 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, unknown>): 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<Record<string, unknown>>;
/** 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<string, unknown>;
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<string, unknown> } | 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<string[]>; get(k: string): Promise<{ value: unknown } | null> }): SourceRead {
return async () => {
const st = get(TRUNK_SOURCE);
const out: Record<string, unknown> = {};
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<string, unknown>, 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`;
}
+29 -2
View File
@@ -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<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((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<string, { heads: Record<string, string> }> = {};
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<string, unknown>) =>
tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args });
const setFull = (args: Record<string, unknown>) =>
@@ -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);
});
+10 -8
View File
@@ -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-<node>, 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),
};
},
},