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.
This commit is contained in:
2026-10-23 15:53:30 +02:00
parent 15378cfba7
commit 77f33d317f
10 changed files with 184 additions and 13 deletions
+1 -1
View File
@@ -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
}
+3 -2
View File
@@ -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()}
@@ -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
+39 -1
View File
@@ -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 {
@@ -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")
}
}
+1 -1
View File
@@ -1 +1 @@
{"repositories": ${config:repositories}}
{"repositories": ${config:repositories}, "keep-main-rules": ${config:keep-main-rules}}
+3 -6
View File
@@ -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<boolean> {
}
}
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());
+32
View File
@@ -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<string, unknown>)["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<string[]>, say: (line: string) => void): () => Promise<void> {
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;
};
}
+8 -1
View File
@@ -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, {\"<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"
]
},
{
+43
View File
@@ -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"]);
});