From 77f33d317f56549d0b5760718c0c004233916e9d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:53:30 +0200 Subject: [PATCH] Keep main's rules only when the setting keep-main-rules is true (default false) The operator, 2026-10-23: "disable the gitea branch protection reset thing". The 10-minute tick and the start-time ensureMainMerges, and the repositories sync's changes to existing repositories' main rules, now run only when the new setting is true; new repositories are still made. The sync says in its outcome that rules were left. --- modules/gitea/cmd/repositories/fake_test.go | 2 +- modules/gitea/cmd/repositories/main.go | 5 +- .../gitea/cmd/repositories/manifest_test.go | 8 +++- .../gitea/cmd/repositories/repositories.go | 40 +++++++++++++++- .../cmd/repositories/repositories_test.go | 47 +++++++++++++++++++ modules/gitea/files/repositories.json | 2 +- modules/gitea/index.ts | 9 ++-- modules/gitea/mainrules.ts | 32 +++++++++++++ modules/gitea/module.json | 9 +++- modules/gitea/test/mainrules.test.ts | 43 +++++++++++++++++ 10 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 modules/gitea/mainrules.ts create mode 100644 modules/gitea/test/mainrules.test.ts diff --git a/modules/gitea/cmd/repositories/fake_test.go b/modules/gitea/cmd/repositories/fake_test.go index b265057d..79962270 100644 --- a/modules/gitea/cmd/repositories/fake_test.go +++ b/modules/gitea/cmd/repositories/fake_test.go @@ -320,7 +320,7 @@ func syncer(t *testing.T, f *fakeForge) (*Syncer, *[]string) { t.Fatal(err) } var said []string - return &Syncer{Forge: forgehttp.Client{URL: srv.URL, User: admin, PasswordFile: pw}, Org: org, Admin: admin, Tools: tools, + return &Syncer{Forge: forgehttp.Client{URL: srv.URL, User: admin, PasswordFile: pw}, Org: org, Admin: admin, Tools: tools, KeepMainRules: true, Log: func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }}, &said } diff --git a/modules/gitea/cmd/repositories/main.go b/modules/gitea/cmd/repositories/main.go index 48b707de..fabb3d65 100644 --- a/modules/gitea/cmd/repositories/main.go +++ b/modules/gitea/cmd/repositories/main.go @@ -48,8 +48,9 @@ func runSync() int { toolsAccount = forgehttp.ToolsAccount } s := &Syncer{Forge: forge, Org: "novox", Admin: forge.User, Tools: toolsAccount, - Log: func(format string, a ...any) { fmt.Printf(format+"\n", a...) }, - WaitFor: 3 * time.Minute, Poll: 5 * time.Second} + Log: func(format string, a ...any) { fmt.Printf(format+"\n", a...) }, + KeepMainRules: ReadKeepMainRules(file), + WaitFor: 3 * time.Minute, Poll: 5 * time.Second} value, err := ReadValue(file) if err != nil { o := Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()} diff --git a/modules/gitea/cmd/repositories/manifest_test.go b/modules/gitea/cmd/repositories/manifest_test.go index 555fab60..75417e23 100644 --- a/modules/gitea/cmd/repositories/manifest_test.go +++ b/modules/gitea/cmd/repositories/manifest_test.go @@ -58,12 +58,18 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) { } filled := filepath.Join(t.TempDir(), "repositories.json") value := `{"mesh-delivery":{"private":false,"description":"d"}}` - if err := os.WriteFile(filled, []byte(strings.ReplaceAll(string(text), "${config:"+ConfigKey+"}", value)), 0o600); err != nil { + if err := os.WriteFile(filled, []byte(strings.ReplaceAll(strings.ReplaceAll(string(text), "${config:"+ConfigKey+"}", value), "${config:keep-main-rules}", "false")), 0o600); err != nil { t.Fatal(err) } if v, err := ReadValue(filled); err != nil || len(v) != 1 || v["mesh-delivery"].Description != "d" { t.Errorf("the filled template reads as %v, %v; want mesh-delivery", v, err) } + if ReadKeepMainRules(filled) { + t.Error("the filled template with keep-main-rules false reads as true") + } + if k := m.Config["keep-main-rules"]; k == nil || k["type"] != "boolean" || k["default"] != false { + t.Errorf("config keep-main-rules = %v, want a boolean defaulting to false", k) + } for _, r := range m.Resources { if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) { process = r diff --git a/modules/gitea/cmd/repositories/repositories.go b/modules/gitea/cmd/repositories/repositories.go index 0bc95701..3e565cbe 100644 --- a/modules/gitea/cmd/repositories/repositories.go +++ b/modules/gitea/cmd/repositories/repositories.go @@ -103,6 +103,30 @@ type Repo struct { Description string `json:"description"` } +// LeftAsSet is what a pass says when keep-main-rules is false. +const LeftAsSet = "main's rules are left as set at the forge (keep-main-rules is false): no existing repository's protection was changed" + +// ReadKeepMainRules reads the setting keep-main-rules from the file the mesh renders; absent or not true is false. +func ReadKeepMainRules(file string) bool { + raw, err := os.ReadFile(file) + if err != nil { + return false + } + var whole struct { + Keep any `json:"keep-main-rules"` + } + if json.Unmarshal(raw, &whole) != nil { + return false + } + switch v := whole.Keep.(type) { + case bool: + return v + case string: + return v == "true" + } + return false +} + // Value is the config value: each repository of the organisation by name. type Value map[string]Repo @@ -147,6 +171,8 @@ type Outcome struct { Org string `json:"org"` Failed string `json:"failed,omitempty"` // the pass did not run: why Repositories []Result `json:"repositories"` + // MainRules says, when keep-main-rules is false, that no existing repository's protection was changed. + MainRules string `json:"main_rules,omitempty"` } // Refused names the repositories a step of was refused. @@ -167,7 +193,11 @@ type Syncer struct { Org string // the organisation, novox Admin string // the setup login, under whose own account a repository is made Tools string // the tools' account, the only one that merges into main - Log func(format string, a ...any) + // KeepMainRules is the setting keep-main-rules: only when true does a pass put right the main rule of a repository + // that already exists or swap statuses on one the value does not list. False (the default) makes new repositories + // and leaves every existing repository's protection as people set it at the forge. + KeepMainRules bool + Log func(format string, a ...any) // WaitFor is how long to wait for the forge to answer; none when zero. WaitFor, Poll time.Duration } @@ -199,6 +229,11 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) { } out.Repositories = append(out.Repositories, r) } + if !s.KeepMainRules { + out.MainRules = LeftAsSet + s.say("%s", LeftAsSet) + return out, nil + } for _, r := range s.switchUnlisted(ctx, v) { if r.Did == DidRefused { s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said) @@ -306,6 +341,9 @@ func (s *Syncer) one(ctx context.Context, name string, r Repo) Result { if err != nil { return refused(name, StepRead, err) } + if inOrg && !s.KeepMainRules { + return Result{Name: name, Did: DidUnchanged} + } if inOrg { changed, err := s.putRight(ctx, s.Org, name) if err != nil { diff --git a/modules/gitea/cmd/repositories/repositories_test.go b/modules/gitea/cmd/repositories/repositories_test.go index 9d551ab2..b97cd061 100644 --- a/modules/gitea/cmd/repositories/repositories_test.go +++ b/modules/gitea/cmd/repositories/repositories_test.go @@ -478,3 +478,50 @@ func keysOf(m map[string]string) []string { } return out } + +// The operator, 2026-10-23: "disable the gitea branch protection reset thing". With keep-main-rules false (the +// default) a pass makes new repositories but writes nothing to any existing repository's protection, listed or not. +func TestKeepMainRulesFalseChangesNoExistingProtection(t *testing.T) { + f := newFake() + hq := f.add("novox/hq", map[string]map[string]any{mainRule: { + "enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate"}, + }}, "main") + old := f.add("novox/old", map[string]map[string]any{mainRule: { + "enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate"}, + }}, "main") + s, said := syncer(t, f) + s.KeepMainRules = false + o := run(t, s, Value{"hq": {}, "fresh": {}}) + + for _, c := range f.writes() { + if strings.Contains(c, "/novox/hq") || strings.Contains(c, "/novox/old") || strings.Contains(c, "/repos/novox/hq") || strings.Contains(c, "/repos/novox/old") { + t.Errorf("an existing repository was written: %s", c) + } + } + if hq.Rules[mainRule]["enable_push"] != true || old.Rules[mainRule]["enable_push"] != true { + t.Error("an existing repository's rule was changed") + } + if f.repos["novox/fresh"] == nil { + t.Error("the new repository was not made") + } + if r := result(o, "hq"); r.Did != DidUnchanged { + t.Errorf("hq outcome %+v", r) + } + if o.MainRules != LeftAsSet || !strings.Contains(strings.Join(*said, "\n"), "keep-main-rules is false") { + t.Errorf("the outcome does not say the rules were left: %q", o.MainRules) + } +} + +func TestReadKeepMainRules(t *testing.T) { + dir := t.TempDir() + for body, want := range map[string]bool{`{}`: false, `{"keep-main-rules": false}`: false, `{"keep-main-rules": true}`: true, `{"keep-main-rules": "true"}`: true, `not json`: false} { + f := filepath.Join(dir, "v.json") + _ = os.WriteFile(f, []byte(body), 0o644) + if got := ReadKeepMainRules(f); got != want { + t.Errorf("%s: got %v, want %v", body, got, want) + } + } + if ReadKeepMainRules(filepath.Join(dir, "missing")) { + t.Error("a missing file must be false") + } +} diff --git a/modules/gitea/files/repositories.json b/modules/gitea/files/repositories.json index 36626ccc..a8293008 100644 --- a/modules/gitea/files/repositories.json +++ b/modules/gitea/files/repositories.json @@ -1 +1 @@ -{"repositories": ${config:repositories}} +{"repositories": ${config:repositories}, "keep-main-rules": ${config:keep-main-rules}} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index fe80595e..b6ed00d0 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -51,6 +51,7 @@ import { state } from "@novox/mesh-sdk/state"; import { announce, keepWatermark, readWatermark } from "./announce.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; +import { keepMainRules, mainRulesStep } from "./mainrules.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained, requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js"; import { GateWriter, clonedAs, type BuildStarted } from "./gate.js"; @@ -578,7 +579,6 @@ async function listenForForge(client: GiteaClient): Promise { } } -let mainTouched = -1; if (gitea) { const client = gitea; // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up @@ -608,11 +608,8 @@ if (gitea) { 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); + // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. + tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000); tick(() => pollRepos(client), 60_000); tick(() => pollMerged(client), () => pace.every()); tick(() => pollPulls(client), () => pace.every()); diff --git a/modules/gitea/mainrules.ts b/modules/gitea/mainrules.ts new file mode 100644 index 00000000..e8ac249f --- /dev/null +++ b/modules/gitea/mainrules.ts @@ -0,0 +1,32 @@ +// The setting keep-main-rules (the operator, 2026-10-23: "disable the gitea branch protection reset thing"): only when it +// is true does the module put main's rule right in every repository of the organisation (client.ts ensureMainMerges). +// False, the default, leaves every repository's branch protection as people set it at the forge. +import { readFileSync } from "node:fs"; + +export const LEFT_AS_SET = "[gitea] main's rules are left as set at the forge (keep-main-rules is false)"; + +/** The setting from the settings-merged config file the mesh delivers; absent, unreadable or not true is false. */ +export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean { + const file = env.MESH_GITEA_CONFIG_FILE; + if (!file) return false; + try { + const v = (JSON.parse(readFileSync(file, "utf8")) as Record)["keep-main-rules"]; + return v === true || v === "true"; + } catch { return false; } +} + +/** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */ +export function mainRulesStep(keep: boolean, put: () => Promise, say: (line: string) => void): () => Promise { + let said = false; + let touched = -1; + return async () => { + if (!keep) { + if (!said) say(LEFT_AS_SET); + said = true; + return; + } + const repos = await put(); + if (repos.length !== touched) say(`[gitea] main's rule is right in ${repos.length} repositories`); + touched = repos.length; + }; +} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 279529fb..9d1856a5 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -170,6 +170,12 @@ "trusted": true, "default": {} }, + "keep-main-rules": { + "type": "boolean", + "description": "Whether the module keeps main's branch protection as the mesh wants it. True: every ten minutes, and at the start, main's rule in every repository of the organisation novox is put to no push and the tools' account alone merging, and each apply of the `repositories` value narrows the main rule of the repositories it lists and swaps mesh/merge-gate for mesh/check in those it does not. False (the default): the module changes no existing repository's branch protection, whoever set it at the forge; a repository listed in `repositories` that does not exist is still made, with its first protection.", + "trusted": true, + "default": false + }, "repositories": { "type": "object", "description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.", @@ -303,7 +309,8 @@ "template": "files/config.json", "merge": "json", "takes-config": [ - "url" + "url", + "keep-main-rules" ] }, { diff --git a/modules/gitea/test/mainrules.test.ts b/modules/gitea/test/mainrules.test.ts new file mode 100644 index 00000000..2a2b18a1 --- /dev/null +++ b/modules/gitea/test/mainrules.test.ts @@ -0,0 +1,43 @@ +// The operator, 2026-10-23: "disable the gitea branch protection reset thing". keep-main-rules false (the default) +// writes nothing at the forge, true puts main's rule right as before. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { keepMainRules, mainRulesStep, LEFT_AS_SET } from "../dist/mainrules.js"; + +function env(body?: string): NodeJS.ProcessEnv { + if (body === undefined) return {}; + const f = join(mkdtempSync(join(tmpdir(), "gitea-keep-")), "config.json"); + writeFileSync(f, body); + return { MESH_GITEA_CONFIG_FILE: f }; +} + +test("the setting is false unless the config says true", () => { + assert.equal(keepMainRules(env()), false); + assert.equal(keepMainRules(env("{}")), false); + assert.equal(keepMainRules(env('{"keep-main-rules":false}')), false); + assert.equal(keepMainRules(env("not json")), false); + assert.equal(keepMainRules(env('{"keep-main-rules":true}')), true); +}); + +test("false: the step never writes and says once that the rules are left", async () => { + let writes = 0; + const said: string[] = []; + const step = mainRulesStep(false, async () => { writes++; return ["a"]; }, (l) => said.push(l)); + await step(); await step(); await step(); + assert.equal(writes, 0); + assert.deepEqual(said, [LEFT_AS_SET]); + assert.match(LEFT_AS_SET, /main's rules are left as set at the forge \(keep-main-rules is false\)/); +}); + +test("true: every step puts the rules right", async () => { + let writes = 0; + const said: string[] = []; + const step = mainRulesStep(true, async () => { writes++; return ["a", "b"]; }, (l) => said.push(l)); + await step(); await step(); + assert.equal(writes, 2); + assert.deepEqual(said, ["[gitea] main's rule is right in 2 repositories"]); +});