From 32ad59fc640253944f6bfb25ca0b5ec2d4f8535e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 23:07:50 +0100 Subject: [PATCH] mesh-delivery-gate and gitea: a setting check-gates-merge; false asks a reviewed pull request's Approve and merges it without waiting for mesh/check The operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses us too much time". Tonight each merge ask waited 3-7 minutes for mesh/check, and every merge into main refused the next approved merge for a check judged on the trunk before (mesh-delivery#52). Both settings default to true, so nothing changes until the operator sets them. False: the gate makes a reviewed pull request ready and asks its Approve with no verdict, a trunk's move puts nothing out of date, a verdict is kept and shown, and a group still merges in one pass; the merge tool merges a head the Approve names whatever its mesh/check says. delivery_status and show say the mode. --- modules/gitea/mainrules.ts | 13 +++++ modules/gitea/merge.ts | 10 +++- modules/gitea/module.json | 9 +++- modules/gitea/test/guard.test.ts | 12 +++++ .../cmd/mesh-delivery-gate/adr505_test.go | 9 ++++ .../cmd/mesh-delivery-gate/events.go | 4 +- .../cmd/mesh-delivery-gate/helpers_test.go | 6 +++ .../cmd/mesh-delivery-gate/holder.go | 8 +-- .../cmd/mesh-delivery-gate/onestatus.go | 7 +++ .../cmd/mesh-delivery-gate/ready.go | 52 ++++++++++++++++++ .../cmd/mesh-delivery-gate/ready_test.go | 53 +++++++++++++++++++ .../cmd/mesh-delivery-gate/settings.go | 25 +++++++++ .../cmd/mesh-delivery-gate/table.go | 18 +++++-- .../cmd/mesh-delivery-gate/table_test.go | 16 ++++++ .../cmd/mesh-delivery-gate/verbs.go | 6 +-- .../mesh-delivery-gate/files/settings.json | 3 +- modules/mesh-delivery-gate/module.json | 9 +++- 17 files changed, 244 insertions(+), 16 deletions(-) diff --git a/modules/gitea/mainrules.ts b/modules/gitea/mainrules.ts index e8ac249f..6137559e 100644 --- a/modules/gitea/mainrules.ts +++ b/modules/gitea/mainrules.ts @@ -15,6 +15,19 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean { } catch { return false; } } +/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses + * us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false + * is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an + * older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */ +export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean { + const file = env.MESH_GITEA_CONFIG_FILE; + if (!file) return true; + try { + const v = (JSON.parse(readFileSync(file, "utf8")) as Record)["check-gates-merge"]; + return !(v === false || v === "false"); + } catch { return true; } +} + /** 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; diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts index a909c743..0505aeb7 100644 --- a/modules/gitea/merge.ts +++ b/modules/gitea/merge.ts @@ -19,6 +19,7 @@ import { mergeableUnder, newestByContext } from "./checks.js"; import { MERGE_METHODS, Refused, checkName } from "./guard.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js"; import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js"; +import { checkGatesMerge } from "./mainrules.js"; import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js"; /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ @@ -31,11 +32,13 @@ export interface MergeDeps { emit?: (type: string, body: unknown) => Promise; stateDir?: string; now?: () => Date; + /** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */ + checkGates?: () => boolean; } export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition { const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR, - now: given.now ?? (() => new Date()) }; + now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) }; return { name: "gitea_merge_pull_request", description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " + @@ -126,7 +129,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition } const kept = maintained(required); const byCheck = required.includes(CHECK_STATUS) || kept.length === 0; - if (byCheck) { + // **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's + // Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without + // approved_heads the check still judges. + if (byCheck && (deps.checkGates() || !approved)) { // **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it // is not current; nothing is written or asked — the checker checks a head again when its trunk moves. const check = checks[CHECK_STATUS]; diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 474d6d55..5eb7d147 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -181,6 +181,12 @@ "trusted": true, "default": {} }, + "check-gates-merge": { + "type": "boolean", + "description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.", + "trusted": true, + "default": true + }, "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.", @@ -316,7 +322,8 @@ "takes-config": [ "url", "keep-main-rules", - "main-mergers" + "main-mergers", + "check-gates-merge" ] }, { diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 06052e70..6f1431aa 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -525,6 +525,18 @@ test("gitea_merge_pull_request judges mesh/check on a main requiring no status, state.statuses = line(TRUNK); assert.equal(((await merge()) as Record).merged, true); + + // check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says; + // without approved_heads it is refused as before. + const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false }); + const mergeUngated = (args: Record = { approved_heads }) => + ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args }); + for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) { + state.merged = false; + state.statuses = statuses; + await assert.rejects(mergeUngated({}), /Approve/); + assert.equal(((await mergeUngated()) as Record).merged, true, JSON.stringify(statuses)); + } }); test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go index 32d82b92..520dbb8b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go @@ -36,6 +36,15 @@ func TestTheBoundsAreSettingsWithTheirDefaults(t *testing.T) { if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour { t.Fatalf("empty file: floor %s, author %s", h.refusalFloor(), h.authorBound()) } + if !h.checkGates() { + t.Fatalf("check-gates-merge unset reads false; the default is true") + } + if s, err = read(`{"check-gates-merge": false}`); err != nil || s.CheckGatesMerge == nil || *s.CheckGatesMerge || h.checkGates() { + t.Fatalf("check-gates-merge false: %+v %v, gates %v", s, err, h.checkGates()) + } + if _, err := read(`{"check-gates-merge": "no"}`); err == nil { + t.Fatalf("a check-gates-merge that is not true or false was taken") + } if _, err := read(`{"author-new-head-minutes": 0}`); err == nil { t.Fatalf("a bound of zero minutes was taken") } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go index 3682f4b3..b0fc97af 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go @@ -67,7 +67,9 @@ func (h *Holder) CommitStatus(e ForgeStatusEvent) { h.takeCheckStatus(e.Owner+"/"+e.Repo, e.SHA, e.State, e.Description) } if e.State != "success" { - h.withdrawForStatus(e) + if h.checkGates() { + h.withdrawForStatus(e) // while the check gates no merge, its status is shown and takes back no ask + } return } repository := strings.ToLower(e.Owner + "/" + e.Repo) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go index d125a164..1bec05c3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go @@ -16,6 +16,12 @@ func verdictMoving(repo string, number int, sha string, _ ...string) CheckedEven return verdict(repo, number, sha, "pass") } +// ungated is the setting check-gates-merge false: mesh/check gates no merge (the operator, 2026-10-25 22:56). +func ungated(w *world) { + off := false + w.h.Settings = func() Settings { return Settings{CheckGatesMerge: &off} } +} + func statusOn(w *world, repo, commit string) string { w.forge.mu.Lock() defer w.forge.mu.Unlock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index 3041be93..cfa5ffe6 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -261,7 +261,7 @@ func (h *Holder) keepGroup(g *Group) { // settleAndKeep moves a delivery as far as its facts allow, and keeps it. func (h *Holder) settleAndKeep(d *Delivery) { - ts := settle(d, Facts{Now: h.Now()}) + ts := settle(d, h.factsAt(h.Now())) h.moved(d, ts) h.closeGroupOf(d) } @@ -378,7 +378,7 @@ func (h *Holder) pullUpdated(p PullEvent) (isNew bool) { // the gate's status on that very commit. A decided one is taken; a pending or errored one is asked again. if v := decidedOnHead(p.HeadChecks, now); v != nil { d.Check = v - ts = append(ts, settle(d, Facts{Now: now})...) + ts = append(ts, settle(d, h.factsAt(now))...) } h.moved(d, ts) if !h.takePlaceOf(replaced, d) { @@ -700,7 +700,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) { } d, ts = fresh, []Transition{t} if d.Check != nil { - ts = append(ts, settle(d, Facts{Now: now})...) + ts = append(ts, settle(d, h.factsAt(now))...) } } else if d == nil { head := p.HeadSHA @@ -742,7 +742,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) { d.Ready.SourceMoved = true } } - ts = append(ts, settle(d, Facts{Now: now})...) + ts = append(ts, settle(d, h.factsAt(now))...) if d.State == Held && d.HeldWhy == "" { d.HeldWhy = "merged without a passing check: only a person decides what follows" } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go index 7822287a..fbc51d15 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go @@ -155,6 +155,13 @@ func (h *Holder) takeCheckStatus(repository, sha, state, desc string) { } h.moved(d, []Transition{t}) } + if d.State == Ready && !h.checkGates() { + // The check gates no merge (check-gates-merge false): its verdict is shown on the delivery, and moves nothing. + d.Check = v + h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s (shown; the check gates no merge)", d.ID, state, oneLine(clip(desc, 120))) + h.keep(d) + return + } if d.State != Proposed { h.keep(d) return // past its verdict: the merge reads the forge's status itself diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go index 0d600ef8..79351e47 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -157,6 +157,7 @@ func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun { func (h *Holder) readyAsks(now time.Time) []func() { var asks []func() h.endStaleAsks() + gates := h.checkGates() ids := make([]string, 0, len(h.deliveries)) for id := range h.deliveries { ids = append(ids, id) @@ -167,6 +168,9 @@ func (h *Holder) readyAsks(now time.Time) []func() { if d.Number == 0 || d.State.Final() || d.MergedAs != "" { continue // not of a pull request, or past its merge } + if !gates { + h.ungate(d, now) + } if d.Ready == nil && d.State == Ready { h.runOf(d, now) h.Poke() // its check, if none is asked, is worth asking now (askCheck) @@ -271,6 +275,30 @@ func (h *Holder) readyAsks(now time.Time) []func() { return asks } +// ungate stands a delivery where it is while mesh/check gates no merge (check-gates-merge false): a proposed one is ready +// with no verdict, and a rejected one, refused on a verdict before the setting changed, is proposed again and so ready. Its +// verdict stays on it, shown. Under the lock. +func (h *Holder) ungate(d *Delivery, now time.Time) { + f := h.factsAt(now) + if d.State == Rejected { + f.By, f.Why = byOwner, "the check gates no merge (check-gates-merge false): its verdict is shown, not waited on" + t, err := Apply(d, EvRecheck, true, f) + if err != nil { + h.refuse(err) + return + } + if d.Ready != nil { + d.Ready.Failed = "" + } + h.moved(d, []Transition{t}) + f.By, f.Why = "", "" + } + if d.State == Proposed { + h.moved(d, settle(d, f)) + h.closeGroupOf(d) + } +} + // sayAfterLine tells a pull request, once, that its description holds an `after: ` line, which is not read // (novox/hq ADR 0419 §2): the order of a delivery comes from the module graph alone, and no refusal asks for the line. // Once per pull request, not per head. Under the lock; the comment is said outside it. @@ -337,6 +365,9 @@ func (r *ReadyRun) noteRefusal(d *Delivery, answer string) { // stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an // Approve waits for that check first. func (h *Holder) readyWaits(d *Delivery) string { + if !h.checkGates() { + return h.ungatedWaits(d) + } if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil { return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head" } @@ -369,6 +400,27 @@ func (h *Holder) readyWaits(d *Delivery) string { return h.groupTurnToMerge(d) } +// ungatedWaits is readyWaits while mesh/check gates no merge (check-gates-merge false): a ready delivery waits only for its +// review at its head, and a group member for every member to be ready; no verdict, composed check or trunk is waited on. +func (h *Holder) ungatedWaits(d *Delivery) string { + if d.State != Ready { + return "it is " + string(d.State) + ", not ready yet" + } + if h.covering(d) == nil { + if w := h.reviewWaits(d); w != "" { + return w + } + } + if g := h.groups[d.Group]; d.Group != "" && g != nil { + for _, m := range h.membersOf(g) { + if m.State != Ready && m.State != Merged { + return "its group " + g.ID + " waits for " + m.ID + ", which is " + string(m.State) + } + } + } + return "" +} + // waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it. const waitsForReview = "waits for a review at " diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go index 6cee746b..e15cc1e1 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go @@ -64,6 +64,43 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) { if len(w.forge.merges) != 1 { t.Fatalf("merged again: %v", w.forge.merges) } + + // check-gates-merge false (the operator, 2026-10-25 22:56): a reviewed pull request is asked with no verdict, another + // merge moving its trunk puts it out of nothing, a failed verdict is shown on it and holds nothing, and the gate's + // status and show say the mode. + u := newWorld(t) + ungated(u) + u.op.auto = false + const other = "3333333333333333333333333333333333333333" + u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go")) + u.settleAll() + if u.op.count() != 2 || u.state(IDOf("novox/app", readyHead)) != Ready { + t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s", u.op.count(), u.state(IDOf("novox/app", readyHead))) + } + if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates { + t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening")) + } + shown, _ := u.h.Show(IDOf("novox/app", other)) + if gates, _ := shown.(map[string]any)["check-gates-merge"].(bool); gates { + t.Fatalf("show does not say the check gates no merge: %v", shown) + } + u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1")) + u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7")) + u.settleAll() + d = u.delivery(IDOf("novox/app", other)) + if d.State != Ready || d.Check == nil || d.Check.Passes() { + t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check) + } + for _, a := range u.op.open() { + if strings.Contains(a, "feat/y") { + u.op.answer(a, OptionApprove) + } + } + u.settleAll() + if len(u.forge.merges) != 1 || u.forge.merges[0] != "novox/app#8@"+other { + t.Fatalf("ungated, the approved head is merged %v; wanted once, with no verdict waited on", u.forge.merges) + } } // 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a @@ -207,6 +244,22 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing. if w.op.count() != 1 { t.Fatalf("a second Approve was asked: %d", w.op.count()) } + + // check-gates-merge false: the group is asked once with no verdict and no composed check, and merges whole in one pass. + u := newWorld(t) + ungated(u) + u.op.auto = false + u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go")) + u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")) + u.settleAll() + if u.op.count() != 1 { + t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count()) + } + u.op.answer(u.op.open()[0], OptionApprove) + u.settleAll() + if len(u.forge.merges) != 2 { + t.Fatalf("ungated, both members merge in one pass: %v", u.forge.merges) + } } // **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a green pull request diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go index 8bda6e77..9d0d016e 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go @@ -25,6 +25,10 @@ const ( type Settings struct { RefusalFloorMinutes *int `json:"refusal-stall-floor-minutes"` AuthorBoundMinutes *int `json:"author-new-head-minutes"` + // CheckGatesMerge is whether mesh/check gates the merge (the default, true). False (the operator, 2026-10-25 22:56: + // "run the check locally only, but not as a gate, it loses us too much time"): a reviewed pull request is ready and its + // Approve asked without a verdict, a trunk's move puts nothing out of date, and a verdict is shown, never waited on. + CheckGatesMerge *bool `json:"check-gates-merge"` } // readSettings reads the settings file; a value that is not a whole number of minutes, one or more, is refused. @@ -55,9 +59,30 @@ func readSettings(path string) (Settings, error) { return Settings{}, fmt.Errorf("%s is not a number", name) } } + switch v := loose["check-gates-merge"].(type) { + case nil: + case bool: + s.CheckGatesMerge = &v + default: + return Settings{}, errors.New("check-gates-merge is not true or false") + } return s, nil } +// checkGates is whether mesh/check gates the merge: true unless the setting check-gates-merge is false. +func (h *Holder) checkGates() bool { + if h.Settings == nil { + return true + } + v := h.Settings().CheckGatesMerge + return v == nil || *v +} + +// factsAt are the facts a settle reads at now: the moment, and whether the check gates the merge. +func (h *Holder) factsAt(now time.Time) Facts { + return Facts{Now: now, Ungated: !h.checkGates()} +} + // minutesOr is a setting's minutes, or the default when it gives none. func minutesOr(n *int, def time.Duration) time.Duration { if n == nil { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go index 8eee0744..f95563a0 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go @@ -84,6 +84,9 @@ type Facts struct { Now time.Time By string Why string + // Ungated is the setting check-gates-merge false: mesh/check gates no merge, so a proposed delivery is accepted with no + // verdict, none is refused for one, and a trunk's move puts none back to proposed. + Ungated bool } // Row is one transition the table holds. @@ -132,8 +135,11 @@ var Table = []Row{ // cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again. {From: []State{Proposed}, Event: EvAccepted, To: Ready, Guard: "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and " + - "the repository's own check did not fail", + "the repository's own check did not fail; or the check gates no merge (check-gates-merge false)", Holds: func(d *Delivery, f Facts) error { + if f.Ungated { + return nil + } if d.Check == nil { return errors.New("no verdict") } @@ -144,8 +150,11 @@ var Table = []Row{ }}, {From: []State{Proposed}, Event: EvRefused, To: Rejected, Guard: "a verdict names this commit and the trunk's head as its trunk commit, and the gate failed or could not run, " + - "or the repository's own check failed", + "or the repository's own check failed; never while the check gates no merge", Holds: func(d *Delivery, f Facts) error { + if f.Ungated { + return errors.New("the check gates no merge (check-gates-merge false)") + } if d.Check == nil { return errors.New("no verdict") } @@ -157,8 +166,11 @@ var Table = []Row{ // **A ready delivery whose verdict went out of date goes back to proposed** (novox/hq ADR 0323 §7): another // merge moved its trunk. A rejected one stays rejected, its status on the forge saying the verdict is out of date. {From: []State{Ready}, Event: EvTrunkMoved, To: Proposed, - Guard: "its verdict names a trunk commit that is not its trunk's head", + Guard: "its verdict names a trunk commit that is not its trunk's head, and the check gates the merge", Holds: func(d *Delivery, f Facts) error { + if f.Ungated { + return errors.New("the check gates no merge (check-gates-merge false)") + } return unless(d.MergedAs == "" && d.outOfDate() != "", "not merged, and its verdict is not out of date") }, Says: func(d *Delivery) string { return d.outOfDate() }}, diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go index 5e0f5bbd..ab2bccbf 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go @@ -155,6 +155,22 @@ func TestTheTableKeepsItsRules(t *testing.T) { if _, err := Apply(d0, EvReask, true, Facts{Now: time.Now(), By: byH2, Why: "stalled"}); err == nil { t.Fatal("a check re-asked once was re-asked again") } + // While the check gates no merge (check-gates-merge false): a proposed delivery is accepted with no verdict or a failed + // one, none is refused, and a trunk's move puts no ready one back to proposed. + off := Facts{Now: time.Now(), Ungated: true} + for _, check := range []*Verdict{nil, {Gate: "fail"}} { + p := &Delivery{ID: "novox/app@c0", State: Proposed, Number: 1, Check: check} + if _, err := Apply(p, EvRefused, false, off); err == nil { + t.Fatalf("ungated, a verdict %+v refused the delivery", check) + } + if _, err := Apply(p, EvAccepted, false, off); err != nil || p.State != Ready { + t.Fatalf("ungated, a delivery with the verdict %+v is %s: %v", check, p.State, err) + } + } + stale := &Delivery{ID: "novox/app@c0", State: Ready, Check: &Verdict{Gate: "pass", Trunk: "d28ca95bbbbb"}, TrunkNow: "690966dccccc"} + if _, err := Apply(stale, EvTrunkMoved, false, off); err == nil { + t.Fatal("ungated, a trunk's move put a ready delivery back to proposed") + } // A ready delivery off the trunk is refused its merge, whatever else is true of it. d := &Delivery{ID: "novox/app@c0", State: Ready} if _, err := Apply(d, EvMerged, false, Facts{Now: time.Now()}); err == nil { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go index f1da969c..627ae019 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -75,7 +75,7 @@ func (h *Holder) Show(id string) (any, error) { h.mu.Lock() defer h.mu.Unlock() if d := h.deliveries[id]; d != nil { - out := map[string]any{"delivery": d, "waits": h.waitsFor(d)} + out := map[string]any{"delivery": d, "waits": h.waitsFor(d), "check-gates-merge": h.checkGates()} if g := h.groups[d.Group]; g != nil { state, why, _ := h.groupState(g, h.membersOf(g)) out["group"] = map[string]any{"group": g, "state": state, "why": why} @@ -85,7 +85,7 @@ func (h *Holder) Show(id string) (any, error) { if g := h.groups[id]; g != nil { members := h.membersOf(g) state, why, _ := h.groupState(g, members) - return map[string]any{"group": g, "state": state, "why": why, "members": members}, nil + return map[string]any{"group": g, "state": state, "why": why, "members": members, "check-gates-merge": h.checkGates()}, nil } return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id) } @@ -286,7 +286,7 @@ func (h *Holder) Status(listening string) map[string]any { byState[string(s)] = n } out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty), - "events": listening, "started": h.started.Format(time.RFC3339)} + "events": listening, "started": h.started.Format(time.RFC3339), "check-gates-merge": h.checkGates()} // The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again, // or given up, and when the last was tried. A count since the start, said with that start: two readings in // one run give a window's tries. diff --git a/modules/mesh-delivery-gate/files/settings.json b/modules/mesh-delivery-gate/files/settings.json index 848f5158..66c66f87 100644 --- a/modules/mesh-delivery-gate/files/settings.json +++ b/modules/mesh-delivery-gate/files/settings.json @@ -1,4 +1,5 @@ { "refusal-stall-floor-minutes": 10, - "author-new-head-minutes": 120 + "author-new-head-minutes": 120, + "check-gates-merge": true } diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index 2129020c..f4166ce0 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -86,6 +86,12 @@ "maximum": 10080, "trusted": false, "default": 120 + }, + "check-gates-merge": { + "type": "boolean", + "description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready and its Approve asked at once, with no verdict waited on; no merge is refused for a check judged on an older trunk; a verdict that comes is shown on the pull request and the delivery, never waited on; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.", + "trusted": false, + "default": true } }, "resources": [ @@ -103,7 +109,8 @@ "mode": "0600", "takes-config": [ "refusal-stall-floor-minutes", - "author-new-head-minutes" + "author-new-head-minutes", + "check-gates-merge" ], "merge": "json", "template": "files/settings.json"