Keep main's rule right at every start and copy a covering glob's checks (issue 948, review round 1)

This commit is contained in:
fix-forge-awg
2026-10-16 21:35:51 +02:00
parent 4215b40547
commit 3cdea45f90
3 changed files with 56 additions and 15 deletions
+19 -14
View File
@@ -6,7 +6,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js"; import { Refused, checkSentPath, ruleCovers, pathSegment, pathSegments } from "./guard.js";
import { isCommit } from "./checks.js"; import { isCommit } from "./checks.js";
import { TOOLS_ACCOUNT } from "./tools-account.js"; import { TOOLS_ACCOUNT } from "./tools-account.js";
@@ -1005,21 +1005,26 @@ export class GiteaAdmin {
if (list.length < 50) break; if (list.length < 50) break;
} }
for (const repo of repos) { for (const repo of repos) {
const rules = await this.branchProtections(org, repo);
const own = rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH);
const rule = `${repoPath(org, repo)}/branch_protections/${MAIN_BRANCH}`; const rule = `${repoPath(org, repo)}/branch_protections/${MAIN_BRANCH}`;
const patch = await this.request(rule, { method: "PATCH", body: JSON.stringify(mainRuleBody()) }); if (own) {
if (patch.status === 200) continue; const patch = await this.request(rule, { method: "PATCH", body: JSON.stringify(mainRuleBody()) });
if (patch.status !== 404) GiteaAdmin.fail(rule, patch); if (patch.status !== 200) GiteaAdmin.fail(rule, patch);
const made = await this.request(`${repoPath(org, repo)}/branch_protections`, { continue;
method: "POST",
body: JSON.stringify({ rule_name: MAIN_BRANCH, enable_status_check: false, status_check_contexts: [], ...mainRuleBody() }),
});
if (made.status === 201) continue;
if (made.status === 403 || made.status === 409 || made.status === 422) {
// A rule made between the two calls: edit it.
const again = await this.request(rule, { method: "PATCH", body: JSON.stringify(mainRuleBody()) });
if (again.status === 200) continue;
} }
GiteaAdmin.fail(`${repoPath(org, repo)}/branch_protections`, made); // A rule named exactly main is matched before any glob rule, so it would hide the statuses, approvals and
// administrator limit a glob rule covering main asks: they are copied into it (issue 948, review round 1).
const covering = rules.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH));
const checks = [...new Set(covering.filter((r) => r.enable_status_check).flatMap((r) => (r.status_check_contexts ?? []).filter(Boolean)))];
const copied: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
const approvals = Math.max(0, ...covering.map((r) => Number(r.required_approvals ?? 0)));
if (approvals > 0) copied.required_approvals = approvals;
if (covering.some((r) => r.block_admin_merge_override === true)) copied.block_admin_merge_override = true;
const made = await this.request(`${repoPath(org, repo)}/branch_protections`, {
method: "POST", body: JSON.stringify({ rule_name: MAIN_BRANCH, ...copied, ...mainRuleBody() }),
});
if (made.status !== 201) GiteaAdmin.fail(`${repoPath(org, repo)}/branch_protections`, made);
} }
return repos; return repos;
} }
+8
View File
@@ -530,6 +530,7 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
} }
} }
let mainTouched = -1;
if (gitea) { if (gitea) {
const client = gitea; const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
@@ -557,6 +558,13 @@ if (gitea) {
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the // **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s. // polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
await listenForForge(client); await listenForForge(client);
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
tick(async () => {
const touched = await GiteaAdmin.fromEnv().ensureMainMerges("novox");
if (touched.length !== mainTouched) console.log(`[gitea] main's rule is right in ${touched.length} repositories`);
mainTouched = touched.length;
}, 600_000);
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), () => pace.every()); tick(() => pollMerged(client), () => pace.every());
tick(() => pollPulls(client), () => pace.every()); tick(() => pollPulls(client), () => pace.every());
+29 -1
View File
@@ -33,7 +33,9 @@ function fakeForge() {
const calls: string[] = []; const calls: string[] = [];
// The organisation's repositories, and the rule each has for main, which the administrator's setup puts right // The organisation's repositories, and the rule each has for main, which the administrator's setup puts right
// (issue 948, ADR 0398 §2). // (issue 948, ADR 0398 §2).
const repos = ["hq", "mesh-catalog"]; const repos = ["hq", "mesh-catalog", "globbed"];
const globRules = new Map<string, any[]>([["globbed", [{ rule_name: "m*", enable_status_check: true,
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true, required_approvals: 1 }]]]);
const mainRules = new Map<string, any>([["hq", { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate"] }]]); const mainRules = new Map<string, any>([["hq", { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate"] }]]);
// What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made // What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made
// between this one's look and its create (gitea answers the create 422). // between this one's look and its create (gitea answers the create 422).
@@ -152,6 +154,9 @@ function fakeForge() {
if (m) { if (m) {
const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin"); const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin");
if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (req.method === "GET" && m[2] === undefined) {
return json(res, 200, [...(mainRules.has(m[1]) ? [mainRules.get(m[1])] : []), ...(globRules.get(m[1]) ?? [])]);
}
if (req.method === "PATCH" && m[2] === "main") { if (req.method === "PATCH" && m[2] === "main") {
const rule = mainRules.get(m[1]); const rule = mainRules.get(m[1]);
if (!rule) return json(res, 404, { message: "branch protection does not exist" }); if (!rule) return json(res, 404, { message: "branch protection does not exist" });
@@ -367,3 +372,26 @@ test("setting the tools' account up puts main's rule right in every repository:
assert.ok(forge.calls.slice(from).filter((c) => /branch_protections/.test(c)).every((c) => c.endsWith(` as ${ADMIN}`)), assert.ok(forge.calls.slice(from).filter((c) => /branch_protections/.test(c)).every((c) => c.endsWith(` as ${ADMIN}`)),
"only the administrator's login touches a rule: the tools' account is no repository's administrator"); "only the administrator's login touches a rule: the tools' account is no repository's administrator");
}); });
test("a glob rule covering main keeps its statuses when main is given a rule of its own name (issue 948, review round 1)", async () => {
const env = await environment();
await fresh(env).listRepos();
const rule = forge.mainRules.get("globbed");
assert.ok(rule, "main is given its own rule");
assert.deepEqual(rule.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
assert.equal(rule.enable_status_check, true);
assert.equal(rule.block_admin_merge_override, true);
assert.equal(rule.required_approvals, 1);
assert.equal(rule.enable_push, false);
assert.deepEqual(rule.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
});
test("a start with the token kept still puts main's rules right: the module's start asks for it each time (issue 948, review round 1)", async () => {
const src = await readFile(new URL("../index.ts", import.meta.url), "utf8");
assert.match(src, /ensureMainMerges\("novox"\)/, "index.ts calls it outside the token's mint");
const { GiteaAdmin } = await import("../dist/client.js");
const env = await environment();
forge.mainRules.delete("hq");
await GiteaAdmin.fromEnv(env).ensureMainMerges("novox");
assert.equal(forge.mainRules.get("hq").enable_push, false, "a rule gone is made again with no token minted");
});