gitea: keep the operator's own login on main's merge whitelist beside mesh-tools (ADR 0534 §2)

The operator merges by hand at the forge when the mesh's own path is stuck,
without changing a rule. The logins come from the trusted config value
main-mergers, merged into the runtime config both programs read.
This commit is contained in:
2026-10-23 12:10:10 +02:00
parent d2036f2a63
commit 79777d0f78
10 changed files with 227 additions and 29 deletions
@@ -1,6 +1,8 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -212,3 +214,50 @@ func TestOrganisationListRefusedIsSaid(t *testing.T) {
t.Errorf("the refused list is not in the outcome: %+v", o)
}
}
// 0534-2 (the operator, 2026-10-23 ~12:05): the operator's own forge login stays on main's merge whitelist beside the
// tools' account, so the operator can always merge by hand at the forge; a rule naming the tools' account alone is put
// right, and a second pass changes nothing.
func TestTheOperatorsLoginStaysBesideTheTools(t *testing.T) {
f := newFake()
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule()}, "main")
s, _ := syncer(t, f)
s.Mergers = []string{"jschoubben", tools, "jschoubben"}
o := run(t, s, Value{"mesh-delivery": {}, "fresh": {}})
for name, rule := range map[string]map[string]any{"mesh-delivery": repo.Rules[mainRule], "fresh": f.repos["novox/fresh"].Rules[mainRule]} {
if got := strings.Join(strs(rule["merge_whitelist_usernames"]), ","); got != tools+",jschoubben" {
t.Errorf("%s's merge whitelist is %s, want %s and jschoubben", name, got, tools)
}
if !requiresNone(rule) || rule["enable_push"] != false {
t.Errorf("%s's rule: %v", name, rule)
}
}
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
t.Errorf("outcome %+v, want put right", r)
}
before := len(f.writes())
run(t, s, Value{"mesh-delivery": {}, "fresh": {}})
if w := f.writes(); len(w) != before {
t.Errorf("a second pass wrote %v", w[before:])
}
}
// 0534-2: the mergers are read from the module's runtime config file; a missing key is none; a name that is no forge login
// is refused.
func TestReadMergers(t *testing.T) {
p := filepath.Join(t.TempDir(), "r.json")
_ = os.WriteFile(p, []byte(`{"url":"","main-mergers":["jschoubben"]}`), 0o600)
if m, err := ReadMergers(p); err != nil || strings.Join(m, ",") != "jschoubben" {
t.Fatalf("%v %v", m, err)
}
_ = os.WriteFile(p, []byte(`{"url":""}`), 0o600)
if m, err := ReadMergers(p); err != nil || len(m) != 0 {
t.Fatalf("%v %v", m, err)
}
for _, bad := range []string{`["a b"]`, `["../x"]`, `[""]`, `"jschoubben"`} {
_ = os.WriteFile(p, []byte(`{"main-mergers":`+bad+`}`), 0o600)
if _, err := ReadMergers(p); err == nil {
t.Errorf("%s was accepted", bad)
}
}
}
+2 -1
View File
@@ -126,7 +126,8 @@ func lets(rule map[string]any, login string) bool {
// and in the organisation the tools' team does (it is on every repository there); nobody else here.
func reaches(full, login string) bool {
owner := strings.SplitN(full, "/", 2)[0]
return owner == login || (owner == org && login == tools)
// The operator's own login is an owner of the organisation, so it reaches every repository there (ADR 0534 §2).
return owner == login || (owner == org && (login == tools || login == "jschoubben"))
}
// keepReaching drops from a rule's merge whitelist every user without access to the repository, as gitea's
+7 -1
View File
@@ -30,7 +30,8 @@ func main() {
func runSync() int {
forge, missing := forgehttp.FromEnv()
file, outcome := os.Getenv("MESH_REPOSITORIES_FILE"), os.Getenv("MESH_REPOSITORIES_OUTCOME")
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome} {
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome,
"MESH_GITEA_CONFIG_FILE": os.Getenv("MESH_GITEA_CONFIG_FILE")} {
if v == "" {
missing = append(missing, k)
}
@@ -56,6 +57,11 @@ func runSync() int {
_ = WriteOutcome(outcome, o)
return Report(o, os.Stderr)
}
if s.Mergers, err = ReadMergers(os.Getenv("MESH_GITEA_CONFIG_FILE")); err != nil {
o := Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()}
_ = WriteOutcome(outcome, o)
return Report(o, os.Stderr)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
o, _ := s.Sync(ctx, value) // a forge not reachable is in o.Failed
@@ -64,6 +64,7 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
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)
}
for _, r := range m.Resources {
if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) {
process = r
@@ -106,6 +107,55 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
}
}
// 0534-2: the manifest declares main-mergers as a trusted list naming the operator's login by default, merges it into
// the module's runtime config file (with url, which the TypeScript part reads), and the repositories program is given
// that file and runs again when it changes.
func TestManifestDeclaresTheMainMergers(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
v := m.Config[MergersKey]
if v == nil || v["type"] != "list" || v["trusted"] != true || strings.Join(strs(v["default"]), ",") != "jschoubben" {
t.Fatalf("config %s = %v, want a trusted list defaulting to the operator's login", MergersKey, v)
}
var cfg, process map[string]any
for _, r := range m.Resources {
if r["id"] == "runtime-config" {
cfg = r
}
if r["name"] == "gitea-repositories" {
process = r
}
}
if cfg == nil || !has(strs(cfg["takes-config"]), MergersKey) || cfg["merge"] != "json" {
t.Fatalf("runtime-config does not take %s: %v", MergersKey, cfg)
}
text, err := os.ReadFile("../../" + cfg["template"].(string))
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "config.json")
_ = os.WriteFile(file, text, 0o600)
if got, err := ReadMergers(file); err != nil || strings.Join(got, ",") != "jschoubben" {
t.Errorf("the template's mergers read as %v, %v; want the default", got, err)
}
env, _ := process["env"].(map[string]any)
if env["MESH_GITEA_CONFIG_FILE"] != cfg["path"] {
t.Errorf("the repositories program reads %v, want %v", env["MESH_GITEA_CONFIG_FILE"], cfg["path"])
}
if !has(strs(process["restart-on"]), "runtime-config") {
t.Errorf("the repositories program does not run again when the mergers change: %v", process["restart-on"])
}
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
+57 -11
View File
@@ -11,6 +11,7 @@ import (
"os"
"path"
"regexp"
"slices"
"sort"
"strings"
"time"
@@ -71,6 +72,37 @@ type Repo struct {
// Value is the config value: each repository of the organisation by name.
type Value map[string]Repo
// MergersKey is the config value naming the forge logins that merge into main beside the tools' account (ADR 0534 §2).
const MergersKey = "main-mergers"
// loginName is a forge login: what a merge whitelist may name.
var loginName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$`)
// ReadMergers reads the main-mergers value from the module's runtime config file, which the mesh merges the value into
// (module.json runtime-config); a file without the key names none.
func ReadMergers(file string) ([]string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("the module's config file cannot be read (%s): %w", file, err)
}
var whole map[string]json.RawMessage
if err := json.Unmarshal(raw, &whole); err != nil {
return nil, fmt.Errorf("the module's config file %s is not JSON: %w", file, err)
}
var out []string
if r, ok := whole[MergersKey]; ok && string(r) != "null" {
if err := json.Unmarshal(r, &out); err != nil {
return nil, fmt.Errorf("%s in %s is not a list of forge logins: %w", MergersKey, file, err)
}
}
for _, n := range out {
if !loginName.MatchString(n) || strings.Contains(n, "..") {
return nil, fmt.Errorf("%s in %s lists %q, which is not a forge login", MergersKey, file, n)
}
}
return out, nil
}
// ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value,
// which makes nothing and touches nothing.
func ReadValue(file string) (Value, error) {
@@ -131,8 +163,11 @@ type Syncer struct {
Forge forgehttp.Client // the setup login's
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)
Tools string // the tools' account, which merges into main for mesh-delivery
// Mergers are the forge logins that merge into main beside the tools' account: the operator's own, so the operator
// can always merge by hand at the forge (ADR 0534 §2; the gitea module's config value main-mergers).
Mergers []string
Log func(format string, a ...any)
// WaitFor is how long to wait for the forge to answer; none when zero.
WaitFor, Poll time.Duration
}
@@ -311,9 +346,9 @@ func (s *Syncer) one(ctx context.Context, name string, r Repo) Result {
return refused(name, StepFirstCommit, err)
}
}
if rule == nil || !isRight(rule, s.Tools) {
if rule == nil || !isRight(rule, s.Tools, s.Mergers) {
if err := s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(s.Admin, name)+"/branch_protections/"+MainBranch,
narrowed(s.Tools), nil); err != nil {
narrowed(s.Tools, s.Mergers), nil); err != nil {
return refused(name, StepNarrow, err)
}
}
@@ -388,10 +423,10 @@ func required(r rule) []string {
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, no
// administrator merging past the rule, as every repository's main has been since 2026-10-07 (issue 1141), and no
// status required (ADR 0534 §1). A field the forge keeps beside these is left as it is.
func narrowed(toolsAccount string) map[string]any {
func narrowed(toolsAccount string, mergers []string) map[string]any {
body := map[string]any{
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{},
"enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount},
"enable_merge_whitelist": true, "merge_whitelist_usernames": whitelist(toolsAccount, mergers),
"block_admin_merge_override": true,
}
for k, v := range noStatus() {
@@ -400,12 +435,23 @@ func narrowed(toolsAccount string) map[string]any {
return body
}
// whitelist is main's merge whitelist: the tools' account first, then each other merger once, in the value's order.
func whitelist(toolsAccount string, mergers []string) []string {
out := []string{toolsAccount}
for _, m := range mergers {
if !slices.Contains(out, m) {
out = append(out, m)
}
}
return out
}
// isRight says whether a rule already is narrowed and requires no status.
func isRight(r rule, toolsAccount string) bool {
func isRight(r rule, toolsAccount string, mergers []string) bool {
if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true {
return false
}
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
if m := strList(r["merge_whitelist_usernames"]); !slices.Equal(m, whitelist(toolsAccount, mergers)) {
return false
}
return r["enable_status_check"] != true && len(strList(r["status_check_contexts"])) == 0 &&
@@ -421,11 +467,11 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error)
return false, err
}
if own := ruleNamed(rules, MainBranch); own != nil {
if isRight(own, s.Tools) {
if isRight(own, s.Tools, s.Mergers) {
return false, nil
}
return true, s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(owner, name)+"/branch_protections/"+MainBranch,
narrowed(s.Tools), nil)
narrowed(s.Tools, s.Mergers), nil)
}
var covering []rule
for _, r := range rules {
@@ -433,7 +479,7 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error)
covering = append(covering, r)
}
}
body := narrowed(s.Tools)
body := narrowed(s.Tools, s.Mergers)
body["rule_name"] = MainBranch
approvals := 0.0
for _, r := range covering {
+1 -1
View File
@@ -1 +1 @@
{}
{"main-mergers": ["jschoubben"]}
+7 -7
View File
@@ -19,8 +19,7 @@
//
// Pure, so it is tested without a forge; the tools do the asking (tools/index.ts).
import { MAIN_BRANCH, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { TOOLS_ACCOUNT } from "./tools-account.js";
import { MAIN_BRANCH, mainMergers, type BranchProtection, type ProtectionWanted } from "./protection.js";
/** A refusal of policy, in the words the caller sees. Never an error of the forge's. */
export class Refused extends Error {
@@ -319,21 +318,22 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro
`operator's, at the forge as themselves. Nothing was changed`;
}
/** Main's rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948): a change
* that enables push, drops the merge whitelist or widens it past that account is refused, whatever the rule was. */
/** Main's rule takes no push and lets only the tools' account and main-mergers merge (novox/hq ADR 0398 §2, issue 948,
* ADR 0534 §2): a change that enables push, drops the merge whitelist or widens it past them is refused, whatever the
* rule was. */
function judgeMain(before: BranchProtection | null, want: ProtectionWanted): string | null {
const why: string[] = [];
if (want.push === true) why.push("it lets a person push to the branch directly");
if (want.mergeWhitelist !== undefined) {
const names = want.mergeWhitelist ?? [];
if (names.length === 0) why.push("it drops the merge whitelist of main");
else if (names.some((n) => n !== TOOLS_ACCOUNT)) {
why.push(`it widens who may merge into main beyond ${TOOLS_ACCOUNT}`);
else if (names.some((n) => !mainMergers().includes(n))) {
why.push(`it widens who may merge into main beyond ${mainMergers().join(", ")}`);
}
}
if (!why.length) return null;
return `the protection of ${before?.rule_name ?? before?.branch_name ?? "main"} is only made stronger through a tool: ` +
`${why.join("; ")}. Main takes no push, and only ${TOOLS_ACCOUNT} merges into it (novox/hq ADR 0398 §2) — ` +
`${why.join("; ")}. Main takes no push, and only ${mainMergers().join(" and ")} merge into it (novox/hq ADR 0398 §2, ADR 0534 §2) — ` +
`weakening that is the operator's, at the forge as themselves. Nothing was changed`;
}
+13 -4
View File
@@ -172,9 +172,15 @@
},
"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.",
"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, the tools' account and main-mergers alone merging, and no status required (ADR 0534), and only then moved into the organisation; one that exists only has main's rule put right so. Every other repository has the statuses its main requires taken off; nothing else of a repository out of the value is touched.",
"trusted": true,
"default": {}
},
"main-mergers": {
"type": "list",
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
"trusted": true,
"default": ["jschoubben"]
}
},
"facts": {
@@ -303,7 +309,8 @@
"template": "files/config.json",
"merge": "json",
"takes-config": [
"url"
"url",
"main-mergers"
]
},
{
@@ -366,10 +373,12 @@
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_TOOLS_USER": "mesh-tools",
"MESH_REPOSITORIES_FILE": "${dir:mesh-state}/repositories.json",
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json"
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json",
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json"
},
"restart-on": [
"repositories-file"
"repositories-file",
"runtime-config"
]
}
],
+26 -4
View File
@@ -3,6 +3,7 @@
// merge tool judge them (ADR 0534 §3, §4), never the forge. Pure, so it is tested without a forge; the client does the
// asking (client.ts).
import { readFileSync } from "node:fs";
import { TOOLS_ACCOUNT } from "./tools-account.js";
/** The branch whose rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948). */
@@ -39,10 +40,31 @@ export function noStatusBody(): Record<string, unknown> {
return { enable_status_check: false, status_check_contexts: [] };
}
/** What main's rule always says: no push, only the tools' account merges (novox/hq ADR 0398 §2, issue 948), and no
* status required (ADR 0534 §1). */
export function mainRuleBody(): Record<string, unknown> {
return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT], ...noStatusBody() };
/** A forge login a merge whitelist may name. */
const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
/** Who merges into main: the tools' account, for mesh-delivery, then each login of the module's config value
* main-mergers once, the operator's own, so the operator can always merge by hand at the forge (novox/hq ADR 0534 §2).
* Read from the module's runtime config file (MESH_GITEA_CONFIG_FILE); a file not there, or a name that is no login,
* leaves the tools' account alone, the narrowest whitelist. */
export function mainMergers(env: NodeJS.ProcessEnv = process.env): string[] {
const out = [TOOLS_ACCOUNT];
let given: unknown = [];
try {
if (env.MESH_GITEA_CONFIG_FILE) given = JSON.parse(readFileSync(env.MESH_GITEA_CONFIG_FILE, "utf8"))["main-mergers"] ?? [];
} catch {
given = [];
}
for (const name of Array.isArray(given) ? given : []) {
if (typeof name === "string" && LOGIN.test(name) && !name.includes("..") && !out.includes(name)) out.push(name);
}
return out;
}
/** What main's rule always says: no push, only the tools' account and main-mergers merge (novox/hq ADR 0398 §2,
* issue 948, ADR 0534 §2), and no status required (ADR 0534 §1). */
export function mainRuleBody(mergers: string[] = mainMergers()): Record<string, unknown> {
return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: mergers, ...noStatusBody() };
}
/** The forge's body for a wanted protection. */
+15
View File
@@ -48,3 +48,18 @@ test("the rule main is put right in the forge's own words: no push, the tools' a
assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"],
enable_status_check: false, status_check_contexts: [] });
});
// The operator's own login stays on main's merge whitelist beside the tools' account (novox/hq ADR 0534 §2): read from
// the module's config value main-mergers, in the runtime config file; nothing there leaves the tools' account alone.
test("main's merge whitelist is the tools' account, then the configured mergers once each", async () => {
const { mainMergers, mainRuleBody } = await import("../dist/protection.js");
const { mkdtempSync, writeFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const file = join(mkdtempSync(join(tmpdir(), "gitea-mergers-")), "config.json");
writeFileSync(file, JSON.stringify({ url: "", "main-mergers": ["jschoubben", "mesh-tools", "jschoubben", "../x", "a b"] }));
assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: file }), ["mesh-tools", "jschoubben"]);
assert.deepEqual(mainMergers({}), ["mesh-tools"]);
assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: join(tmpdir(), "no-such-gitea-config.json") }), ["mesh-tools"]);
assert.deepEqual(mainRuleBody(["mesh-tools", "jschoubben"]).merge_whitelist_usernames, ["mesh-tools", "jschoubben"]);
});