diff --git a/.gitignore b/.gitignore index bda87780..815ac8d4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ modules/brother/cmd/brother/brother modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go modules/mesh-config/mesh-config modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image +*.test diff --git a/checks/config/go.mod b/checks/config/go.mod index c651a52b..10bdf579 100644 --- a/checks/config/go.mod +++ b/checks/config/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/config go 1.22 + +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 + +replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/config/realtree_test.go b/checks/config/realtree_test.go new file mode 100644 index 00000000..cfad030e --- /dev/null +++ b/checks/config/realtree_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-catalog/checks/trunk" +) + +// The script's comparison of this list with the trunk's (novox/hq ADR 0529 clause 2): the list of modules not yet moved gains none +// the trunk's copy at the merge base does not hold. The catalogue itself is judged against the list by +// TestTheCatalogueSaysConfigInEveryMovedModule. Skipped, and said, where no trunk is beside. +func TestTheUnmovedListHoldsNoModuleTheTrunksDoesNot(t *testing.T) { + path, ok := trunk.Copy(t, "checks/config/unmoved") + found, err := Check(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + unmoved, err := Unmoved("unmoved") + if err != nil { + t.Fatal(err) + } + base := seed + if ok { + if base, err = Unmoved(path); err != nil { + t.Fatal(err) + } + } + _, _, added := Judge(found, unmoved, base) + for _, m := range added { + t.Errorf("checks/config/unmoved gains %s, which the trunk's copy does not hold: the list only shrinks", m) + } +} diff --git a/checks/health/go.mod b/checks/health/go.mod new file mode 100644 index 00000000..a4d884a2 --- /dev/null +++ b/checks/health/go.mod @@ -0,0 +1,5 @@ +module checks/health + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 diff --git a/checks/health/go.sum b/checks/health/go.sum new file mode 100644 index 00000000..fc336ec2 --- /dev/null +++ b/checks/health/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/checks/health/health.go b/checks/health/health.go new file mode 100644 index 00000000..0797da65 --- /dev/null +++ b/checks/health/health.go @@ -0,0 +1,70 @@ +// Package health is the catalogue's count of long-running resources that do not say how they are ready (novox/hq ADR 0240 rule +// 8), kept in health-undeclared and judged by a test over the real tree (ADR 0529 clauses 2 and 4): it replaces the step of +// merge-check.sh that read it out of the controller's `module check`. Every manifest is read through mesh-sdk's catalogue +// package, the one the controller counts with. +package health + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Count is the long-running resources without `health` of every module under root/modules. +func Count(root string) (int, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return 0, err + } + sort.Strings(manifests) + if len(manifests) == 0 { + return 0, fmt.Errorf("no manifest under %s: the count would be zero for any catalogue", filepath.Join(root, "modules")) + } + n := 0 + for _, path := range manifests { + m, err := catalogue.ReadTreeManifest(root, filepath.Dir(path)) + if err != nil { + return 0, fmt.Errorf("%s: %w", path, err) + } + n += len(catalogue.Undeclared(m)) + } + return n, nil +} + +// Kept is the number the file keeps: the first number of its first line. +func Kept(path string) (int, error) { + raw, err := os.ReadFile(path) + if err != nil { + return 0, err + } + first, _, _ := strings.Cut(string(raw), "\n") + digits := "" + for _, r := range strings.TrimSpace(first) { + if r < '0' || r > '9' { + break + } + digits += string(r) + } + n, err := strconv.Atoi(digits) + if err != nil { + return 0, fmt.Errorf("%s does not begin with the number it keeps: %q", path, first) + } + return n, nil +} + +// Judge says what is wrong with a count against the kept number; empty when they agree. It only goes down: a rise is refused, +// and a fall asks the new number written in health-undeclared, so the count cannot rise again. +func Judge(kept, counted int) string { + switch { + case counted > kept: + return fmt.Sprintf("the long-running resources without health rose from %d to %d: declare how each new one is ready (ADR 0240 rule 8)", kept, counted) + case counted < kept: + return fmt.Sprintf("the long-running resources without health fell from %d to %d: write %d in health-undeclared, so the count cannot rise again", kept, counted, counted) + } + return "" +} diff --git a/checks/health/health_test.go b/checks/health/health_test.go new file mode 100644 index 00000000..c0b445b5 --- /dev/null +++ b/checks/health/health_test.go @@ -0,0 +1,88 @@ +package health + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func aCatalogue(t *testing.T, manifests map[string]string) string { + t.Helper() + root := t.TempDir() + for module, body := range manifests { + dir := filepath.Join(root, "modules", module) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +// A service that stays up and does not say how it is ready is counted; one that says, and a file, are not. +func TestOnlyALongRunningResourceWithoutHealthIsCounted(t *testing.T) { + root := aCatalogue(t, map[string]string{ + "quiet": `{"module":"quiet","resources":[{"id":"svc","type":"service","unit":"quiet.service","state":"running"}]}`, + "says": `{"module":"says","resources":[{"id":"svc","type":"service","unit":"says.service","state":"running",` + + `"health":{"kind":"unit"}}]}`, + "file": `{"module":"file","resources":[{"id":"cfg","type":"file","path":"/etc/file.conf","content":"x"}]}`, + }) + counted, err := Count(root) + if err != nil { + t.Fatal(err) + } + if counted != 1 { + t.Fatalf("counted %d, wanted the one quiet service", counted) + } +} + +func TestTheCountMayOnlyGoDown(t *testing.T) { + for _, c := range []struct { + kept, counted int + says string + }{ + {58, 58, ""}, + {58, 59, "rose from 58 to 59"}, + {58, 57, "fell from 58 to 57"}, + } { + got := Judge(c.kept, c.counted) + if (c.says == "") != (got == "") || !strings.Contains(got, c.says) { + t.Errorf("kept %d counted %d: %q, wanted %q", c.kept, c.counted, got, c.says) + } + } +} + +func TestTheKeptCountIsTheFirstNumberOfTheFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "health-undeclared") + if err := os.WriteFile(path, []byte("58\nThe long-running resources ...\n"), 0o644); err != nil { + t.Fatal(err) + } + if n, err := Kept(path); err != nil || n != 58 { + t.Fatalf("%d %v", n, err) + } + if err := os.WriteFile(path, []byte("many\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Kept(path); err == nil { + t.Fatal("a file with no number gave a count") + } +} + +// The script's `module check` run over the real tree (novox/hq ADR 0240 rule 8, ADR 0529 clause 2 and 4): the long-running +// resources that do not say how they are ready, counted as the controller counts them, are the number health-undeclared keeps. +func TestTheCatalogueSaysHowItsLongRunningResourcesAreReady(t *testing.T) { + kept, err := Kept(filepath.Join("..", "..", "health-undeclared")) + if err != nil { + t.Fatal(err) + } + counted, err := Count(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + if say := Judge(kept, counted); say != "" { + t.Fatal(say) + } +} diff --git a/checks/locks/main_test.go b/checks/locks/main_test.go index 958a706f..11aab626 100644 --- a/checks/locks/main_test.go +++ b/checks/locks/main_test.go @@ -61,3 +61,15 @@ func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) { t.Fatalf("%v %v", got, err) } } + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every TypeScript module that names packages +// beyond the SDK commits its lock. +func TestTheCatalogueCommitsTheLocksItNeeds(t *testing.T) { + found, err := Check("../..") + if err != nil { + t.Fatal(err) + } + for _, f := range found { + t.Errorf("%s names packages beyond the SDK (%v) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)", f.Module, f.Packages) + } +} diff --git a/checks/noscripts/go.mod b/checks/noscripts/go.mod new file mode 100644 index 00000000..e1554e3c --- /dev/null +++ b/checks/noscripts/go.mod @@ -0,0 +1,3 @@ +module checks/noscripts + +go 1.22 diff --git a/checks/noscripts/noscripts_test.go b/checks/noscripts/noscripts_test.go new file mode 100644 index 00000000..a96022c4 --- /dev/null +++ b/checks/noscripts/noscripts_test.go @@ -0,0 +1,19 @@ +package noscripts + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// The catalogue's checks are Go tests (novox/hq ADR 0529 clause 5, ADR 0445): the checker runs every nested Go module's tests by +// the language's convention and no script at the root says what else to run. A script back at the root would be a check nobody +// runs (ADR 0503 §3: since R4e the checker has not run them), so it is refused. +func TestNoScriptOfTheOldCheckStandsAtTheRoot(t *testing.T) { + for _, name := range []string{"merge-check.sh", "merge-check-typescript.sh", "check-nested-modules.sh"} { + if _, err := os.Stat(filepath.Join("..", "..", name)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s stands at the catalogue's root (%v): what it ran is a Go test in checks/ or a step of the build module", name, err) + } + } +} diff --git a/checks/owned/go.mod b/checks/owned/go.mod index d1c4739a..ec64d678 100644 --- a/checks/owned/go.mod +++ b/checks/owned/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/owned go 1.22 + +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 + +replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/owned/realtree_test.go b/checks/owned/realtree_test.go new file mode 100644 index 00000000..b0cb1e00 --- /dev/null +++ b/checks/owned/realtree_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "testing" + + "git.novox.be/novox/mesh-catalog/checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): every rule holds. +func TestTheCatalogueKeepsEveryRuleOfOwnedPaths(t *testing.T) { + found, err := Check("../..", nil) + if err != nil { + t.Fatal(err) + } + for _, f := range found { + t.Errorf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8)", f.Module, f.ID, f.Why, f.Rule) + } +} + +// And the list of system directories' known exceptions holds no line the trunk's copy at the merge base does not (rule 5). A trunk +// with none had no list: its first copy. Skipped, and said, where no trunk is beside. +func TestTheSystemListHoldsNoLineTheTrunksDoesNot(t *testing.T) { + path, ok := trunk.Copy(t, "checks/owned/system-known") + var base []string + if ok { + lines, err := readLines(path) + if err != nil { + t.Fatal(err) + } + base = append([]string{}, lines...) + } + found, err := Check("../..", base) + if err != nil { + t.Fatal(err) + } + for _, f := range found { + if f.Rule == 5 { + t.Errorf("%s: %s: %s (rule %d, issue 1841)", f.Module, f.ID, f.Why, f.Rule) + } + } +} diff --git a/checks/particulars/main_test.go b/checks/particulars/main_test.go index 9522c00f..f348a703 100644 --- a/checks/particulars/main_test.go +++ b/checks/particulars/main_test.go @@ -114,3 +114,10 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) { t.Errorf("got %v", got) } } + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module. +func TestTheCoveredModulesNameNoNode(t *testing.T) { + if code := check("../.."); code != 0 { + t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code) + } +} diff --git a/checks/paths/go.mod b/checks/paths/go.mod index bb495055..630a93ff 100644 --- a/checks/paths/go.mod +++ b/checks/paths/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/paths go 1.22 + +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 + +replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/paths/realtree_test.go b/checks/paths/realtree_test.go new file mode 100644 index 00000000..d42f7f5a --- /dev/null +++ b/checks/paths/realtree_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-catalog/checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no module names another's state on its machine, +// and the known list holds only what still occurs. +func TestNoModuleNamesAnothersState(t *testing.T) { + root := filepath.Join("..", "..") + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + shared, _, err := list("shared") + if err != nil { + t.Fatal(err) + } + known, _, err := list("known") + if err != nil { + t.Fatal(err) + } + refused, gone := Judge(found, shared, known) + for _, f := range refused { + t.Error(f) + } + for _, k := range gone { + t.Errorf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back", k) + } +} + +// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Skipped, +// and said, where no trunk is beside. +func TestTheListsOfPathsHoldNoLineTheTrunksDoNot(t *testing.T) { + sharedPath, okShared := trunk.Copy(t, "checks/paths/shared") + knownPath, okKnown := trunk.Copy(t, "checks/paths/known") + if !okShared || !okKnown { + t.Log("the trunk holds not both lists: their first copies, to which nothing was added") + return + } + shared, sharedWhy, err := list("shared") + if err != nil { + t.Fatal(err) + } + known, knownWhy, err := list("known") + if err != nil { + t.Fatal(err) + } + baseShared, _, err := list(sharedPath) + if err != nil { + t.Fatal(err) + } + baseKnown, _, err := list(knownPath) + if err != nil { + t.Fatal(err) + } + for _, g := range append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...) { + t.Error(g) + } +} diff --git a/checks/paths/shared b/checks/paths/shared index e305bcff..8492ae56 100644 --- a/checks/paths/shared +++ b/checks/paths/shared @@ -1,3 +1,4 @@ # Paths every program on a machine reads as the machine's own, not as a message from the module that # writes them (checks/paths, novox/hq ADR 0260). One per line, with why. /etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them +/var/lib/mesh-build-node-cache # allow: the Go build module gives its integration level the node's installed Node toolchains read-only, so the Node build module's tests build offline (issue 1891, ADR 0533) diff --git a/checks/predefined/go.mod b/checks/predefined/go.mod index da843dfb..00332a58 100644 --- a/checks/predefined/go.mod +++ b/checks/predefined/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/predefined go 1.22 + +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 + +replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/predefined/realtree_test.go b/checks/predefined/realtree_test.go new file mode 100644 index 00000000..af553705 --- /dev/null +++ b/checks/predefined/realtree_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "path/filepath" + "sort" + "testing" + + "git.novox.be/novox/mesh-catalog/checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): predefined.json removes no key a module still +// names, judged against the trunk's copy at the merge base. A trunk with none had no keys; with no trunk to read, nothing can be +// seen removed, and the test says so. +func TestThePredefinedListRemovesNoKeyAModuleStillNames(t *testing.T) { + tree, err := Keys(filepath.Join("..", "..", File)) + if err != nil { + t.Fatal(err) + } + path, ok := trunk.Copy(t, File) + if !ok { + t.Log("no trunk copy of " + File + ": no removal can be seen") + return + } + base, err := Keys(path) + if err != nil { + t.Fatal(err) + } + named, err := Named(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + removed := Removed(base, tree, named) + keys := make([]string, 0, len(removed)) + for k := range removed { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + t.Errorf("%q is removed from %s and still named by %v", k, File, removed[k]) + } +} diff --git a/checks/secrets/go.mod b/checks/secrets/go.mod index 1a0dd13d..c775c684 100644 --- a/checks/secrets/go.mod +++ b/checks/secrets/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/secrets go 1.22 + +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 + +replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/secrets/realtree_test.go b/checks/secrets/realtree_test.go new file mode 100644 index 00000000..f552076e --- /dev/null +++ b/checks/secrets/realtree_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "testing" + + "git.novox.be/novox/mesh-catalog/checks/trunk" +) + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): the known list gains no line the trunk's copy +// at the merge base does not hold. Without a trunk copy the lines in seed are the trunk's. +func TestTheKnownListHoldsNoLineTheTrunksDoesNot(t *testing.T) { + known, err := Known("known") + if err != nil { + t.Fatal(err) + } + base := seed + if path, ok := trunk.Copy(t, "checks/secrets/known"); ok { + if base, err = Known(path); err != nil { + t.Fatal(err) + } + } + for _, line := range Added(known, base) { + t.Errorf("checks/secrets/known gains %q, which the trunk's copy does not hold: the list only shrinks — protect the key instead (novox/hq issue 342)", line) + } +} diff --git a/checks/templates/go.mod b/checks/templates/go.mod index f6c19294..60962f19 100644 --- a/checks/templates/go.mod +++ b/checks/templates/go.mod @@ -1,3 +1,7 @@ module git.novox.be/novox/mesh-catalog/checks/templates go 1.22 + +require git.novox.be/novox/mesh-catalog/checks/trunk v0.0.0 + +replace git.novox.be/novox/mesh-catalog/checks/trunk => ../trunk diff --git a/checks/templates/realtree_test.go b/checks/templates/realtree_test.go new file mode 100644 index 00000000..7832a7b1 --- /dev/null +++ b/checks/templates/realtree_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "testing" + + "git.novox.be/novox/mesh-catalog/checks/trunk" +) + +// The script's comparison of this list with the trunk's (novox/hq ADR 0529 clause 2): the list of modules keeping file text inline +// gains none the trunk's copy at the merge base does not hold. The catalogue itself is judged against the list by +// TestTheCatalogueKeepsFileTextInlineOnlyWhereListed. Skipped, and said, where no trunk is beside. +func TestTheInlineListHoldsNoModuleTheTrunksDoesNot(t *testing.T) { + path, ok := trunk.Copy(t, "checks/templates/inline") + found, err := Check("../..") + if err != nil { + t.Fatal(err) + } + listed, err := Listed(listFile) + if err != nil { + t.Fatal(err) + } + base := seed + if ok { + if base, err = Listed(path); err != nil { + t.Fatal(err) + } + } + _, _, added := Judge(found, listed, base) + for _, m := range added { + t.Errorf("checks/templates/inline gains %s, which the trunk's copy does not hold: the list only shrinks", m) + } +} diff --git a/checks/trunk/go.mod b/checks/trunk/go.mod new file mode 100644 index 00000000..952c764e --- /dev/null +++ b/checks/trunk/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/trunk + +go 1.22 diff --git a/checks/trunk/trunk.go b/checks/trunk/trunk.go new file mode 100644 index 00000000..e3bc86f3 --- /dev/null +++ b/checks/trunk/trunk.go @@ -0,0 +1,69 @@ +// Package trunk is where a catalogue check finds the trunk's copy of a list that may only shrink (novox/hq ADR 0529 clause 2): +// the file as the catalogue's base commit holds it, to judge a change's list against. +// +// The checker fetches the change's own repository at its base commit beside the change, as it fetches a sibling, and says where +// in MESH_CHECK_BESIDE; the copy is under the repository's name (the same convention the other repositories' tests use). With +// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the comparison is skipped and the test says NOT COMPARED +// WITH THE TRUNK, naming why: the checker that runs before R4g's delivery places siblings but not the change's own repository, +// so R4g's own check cannot hold the copy (issue 1295 notes that a missing copy becomes a failure once that checker is gone). A trunk that holds no such file had +// none: the list's first copy, which nothing was added to. +package trunk + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// Repository is the catalogue's name beside a check. +const Repository = "mesh-catalog" + +// BesideEnv is where a merge check says the clones beside the one checked are. +const BesideEnv = "MESH_CHECK_BESIDE" + +// Copy is the trunk's copy of the file at rel (a path from the catalogue's root), written to a file of the test's own: its path, +// and whether the trunk holds one (false: its first copy). It skips the test, saying so, when no copy of the catalogue is beside. +func Copy(t testing.TB, rel string) (string, bool) { + t.Helper() + raw, found, skip, err := Read(rel) + switch { + case skip != "": + t.Skip(skip) + case err != nil: + t.Fatal(err) + case !found: + t.Logf("the trunk holds no %s: its first copy", rel) + return "", false + } + path := filepath.Join(t.TempDir(), filepath.Base(rel)) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + return path, true +} + +// Read is the trunk's copy of rel and whether the trunk holds one. skip is the words to skip the comparison with, when no trunk is +// named; err is a trunk named and not there, or unreadable. +func Read(rel string) (content []byte, found bool, skip string, err error) { + beside := os.Getenv(BesideEnv) + if beside == "" { + return nil, false, "NOT COMPARED WITH THE TRUNK: " + BesideEnv + " is not set, so the trunk's copy of " + rel + + " is not beside this run; the checker places it", nil + } + dir := filepath.Join(beside, Repository) + if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) { + return nil, false, "NOT COMPARED WITH THE TRUNK: " + Repository + " is not beside this check in " + BesideEnv + "=" + beside + + ", so " + rel + " is not judged against the trunk's copy (the checker before R4g places no copy of the change's own repository)", nil + } else if err != nil { + return nil, false, "", err + } + raw, err := os.ReadFile(filepath.Join(dir, rel)) + if errors.Is(err, os.ErrNotExist) { + return nil, false, "", nil + } + if err != nil { + return nil, false, "", err + } + return raw, true, "", nil +} diff --git a/checks/trunk/trunk_test.go b/checks/trunk/trunk_test.go new file mode 100644 index 00000000..d8283f4d --- /dev/null +++ b/checks/trunk/trunk_test.go @@ -0,0 +1,43 @@ +package trunk + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTheCloneBesideTheCheckIsTheTrunk(t *testing.T) { + beside := t.TempDir() + dir := filepath.Join(beside, Repository, "checks", "x") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "known"), []byte("a\nb\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(BesideEnv, beside) + raw, found, skip, err := Read("checks/x/known") + if err != nil || skip != "" || !found || string(raw) != "a\nb\n" { + t.Fatalf("read %q found %v skip %q err %v", raw, found, skip, err) + } + if _, found, skip, err := Read("checks/x/absent"); err != nil || skip != "" || found { + t.Fatalf("a file the trunk does not hold: found %v skip %q err %v; wanted its first copy", found, skip, err) + } +} + +func TestWithNothingBesideTheComparisonIsSkippedAndSaidNotMade(t *testing.T) { + t.Setenv(BesideEnv, "") + _, found, skip, err := Read("checks/x/known") + if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" { + t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED", found, skip, err) + } +} + +func TestACloneBesideWithoutTheCatalogueIsSkippedAndSaidNotMade(t *testing.T) { + t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not (the checker before R4g) + _, found, skip, err := Read("checks/x/known") + if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" || !strings.Contains(skip, "not beside") { + t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED naming the missing copy", found, skip, err) + } +} diff --git a/checks/words/buildagent-allowed b/checks/words/buildagent-allowed new file mode 100644 index 00000000..916dafcf --- /dev/null +++ b/checks/words/buildagent-allowed @@ -0,0 +1,3 @@ +# The modules of the catalogue whose texts still name the build agent or the toolchain images, and why (checks/words, +# buildagent_test.go). One module directory a line, with its reason after a #. It only shrinks. +modules/mesh-build-go # build.go:45 says "the build agent's recipe" in a step's description: the asks lane's module (issue 1887), not E6's to edit diff --git a/checks/words/buildagent_test.go b/checks/words/buildagent_test.go new file mode 100644 index 00000000..febc6e29 --- /dev/null +++ b/checks/words/buildagent_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// The build agent and the toolchain images are gone (novox/hq ADR 0524 §5, issue 1295 and E6 of milestone R4g): no text a module +// can show names them. Where the word stands for a reason that is another lane's or still waits for its change, buildagent-allowed +// names the module's directory with that reason and the date it is dated from; the list only shrinks: a directory on it that no +// longer names the word is refused, so the line is taken out. +var gone = []string{"build-agent", "build agent", "toolchain image"} + +func allowedModules(t *testing.T) map[string]string { + t.Helper() + raw, err := os.ReadFile("buildagent-allowed") + if err != nil { + t.Fatal(err) + } + out := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + entry, why, _ := strings.Cut(line, "#") + if entry = strings.TrimSpace(entry); entry != "" && !strings.HasPrefix(line, "#") { + if strings.TrimSpace(why) == "" { + t.Fatalf("%q is allowed without a reason", entry) + } + out[entry] = strings.TrimSpace(why) + } + } + return out +} + +func TestNoTextOfTheCatalogueNamesTheBuildAgentOutsideTheModulesAllowed(t *testing.T) { + texts, _, err := collect("../..") + if err != nil { + t.Fatal(err) + } + allowed := allowedModules(t) + seen := map[string]bool{} + for _, f := range check(gone, texts) { + module := f + if rest, ok := strings.CutPrefix(f, "modules/"); ok { + module = "modules/" + strings.SplitN(rest, "/", 2)[0] + } + if _, ok := allowed[module]; ok { + seen[module] = true + continue + } + t.Errorf("%s (the build agent and the toolchain images are gone: say what stands in their place)", f) + } + for module, why := range allowed { + if !seen[module] { + t.Errorf("buildagent-allowed names %s (%s), which names the word no more: take the line out, so it cannot come back", module, why) + } + } +} diff --git a/checks/words/lines.go b/checks/words/lines.go index 87c68496..8a6020e5 100644 --- a/checks/words/lines.go +++ b/checks/words/lines.go @@ -58,6 +58,9 @@ func checkLines(lines []retiredLine, texts []text) []string { for _, l := range lines { rx := linePattern(l) for _, t := range texts { + if !strings.Contains(t.s, l.daemon) { // the pattern holds the daemon's name whole: the cheap test first + continue + } if m := rx.FindStringSubmatchIndex(t.s); m != nil { line := t.line if line > 0 { diff --git a/checks/words/main.go b/checks/words/main.go index 2ef0d32a..9db6b1e6 100644 --- a/checks/words/main.go +++ b/checks/words/main.go @@ -43,18 +43,63 @@ func main() { if len(os.Args) > 1 { root = os.Args[1] } + os.Exit(run(root)) +} + +// run reads every text a module can show under root and answers the command's exit code: 0 clean, 1 a finding, 2 a list or a +// file unreadable. The command prints what it found; a test of the real catalogue reads the code. +func run(root string) int { words, err := readList(filepath.Join(root, "retired-words")) if err != nil { fmt.Fprintln(os.Stderr, "words:", err) - os.Exit(2) + return 2 } if len(words) == 0 { fmt.Fprintln(os.Stderr, "words: retired-words lists no word — the check would pass on anything") - os.Exit(2) + return 2 } + texts, files, err := collect(root) + if err != nil { + fmt.Fprintln(os.Stderr, "words:", err) + return 2 + } + findings := check(words, texts) + for _, f := range findings { + fmt.Println(f) + } + if len(findings) > 0 { + fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings)) + return 1 + } + // And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line. + lines, err := readLines(filepath.Join(root, "retired-lines")) + if err != nil { + fmt.Fprintln(os.Stderr, "words:", err) + return 2 + } + if len(lines) == 0 { + fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything") + return 2 + } + if findings := checkLines(lines, texts); len(findings) > 0 { + for _, f := range findings { + fmt.Println(f) + } + fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n", + len(findings)) + return 1 + } + fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines), + len(texts), files) + return 0 +} + +// collect reads every text a module can show under root: the strings of its own code that is not a test, and every string of +// its manifests. +func collect(root string) ([]text, int, error) { var texts []text files := 0 - err = filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(filepath.Join(root, "modules"), func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -87,38 +132,7 @@ func main() { texts = append(texts, found...) return nil }) - if err != nil { - fmt.Fprintln(os.Stderr, "words:", err) - os.Exit(2) - } - findings := check(words, texts) - for _, f := range findings { - fmt.Println(f) - } - if len(findings) > 0 { - fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings)) - os.Exit(1) - } - // And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line. - lines, err := readLines(filepath.Join(root, "retired-lines")) - if err != nil { - fmt.Fprintln(os.Stderr, "words:", err) - os.Exit(2) - } - if len(lines) == 0 { - fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything") - os.Exit(2) - } - if findings := checkLines(lines, texts); len(findings) > 0 { - for _, f := range findings { - fmt.Println(f) - } - fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n", - len(findings)) - os.Exit(1) - } - fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines), - len(texts), files) + return texts, files, err } // readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words. @@ -152,9 +166,22 @@ func pattern(word string) *regexp.Regexp { func check(words []string, texts []text) []string { var out []string + // A text can hold a word only if it holds the word's first part, in any case: the regular expression is run on those alone, + // which keeps the check of the whole catalogue within the unit level's bound. + lower := make([]string, len(texts)) + for i, t := range texts { + lower[i] = strings.ToLower(t.s) + } for _, w := range words { rx := pattern(w) - for _, t := range texts { + first := "" + if parts := strings.Fields(w); len(parts) > 0 { + first = strings.ToLower(parts[0]) + } + for i, t := range texts { + if !strings.Contains(lower[i], first) { + continue + } if m := rx.FindStringSubmatchIndex(t.s); m != nil { line := t.line if line > 0 { diff --git a/checks/words/main_test.go b/checks/words/main_test.go index 84388d14..79d8f64a 100644 --- a/checks/words/main_test.go +++ b/checks/words/main_test.go @@ -87,3 +87,11 @@ func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) { t.Fatal("a line with no command was read") } } + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no word the glossary retired, and no +// daemon's line, in any text a module's tools can show an agent. +func TestTheCatalogueHoldsNoRetiredWordAndNoDaemonsLine(t *testing.T) { + if code := run("../.."); code != 0 { + t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code) + } +} diff --git a/health-undeclared b/health-undeclared index 9ce896e4..ad3ead71 100644 --- a/health-undeclared +++ b/health-undeclared @@ -1,5 +1,5 @@ 58 The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240 -rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and +rule 8), as `module check` counts them. It may only go down: checks/health fails a change that raises it, and one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running resource without `health` is refused. diff --git a/merge-check-typescript.sh b/merge-check-typescript.sh deleted file mode 100644 index b32bb08f..00000000 --- a/merge-check-typescript.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/bin/sh -# The catalogue's TypeScript part of its own check (novox/hq issue 302's `mesh-check-also`), declared in -# merge-check.sh's header and run by the build seat in the TypeScript toolchain, after the Go part. The media -# catalogue's twin is issue 311. Before this, a TypeScript module's change ran nothing of its own before it -# merged: the Go part said "NOT TESTED HERE", and a type error in the forge module would first have shown in -# its walk. -# -# For every touched module that has a package.json, in a copy of it outside the clone: -# -# 1. its dependencies are the toolchain's own (/app/node_modules: the mesh's SDK, Node's types and what the -# runtime carries), copied beside it, never fetched: the check holds no credential for the package -# registry. A module that depends on a package the toolchain does not hold **fails the check**, by name: -# it could not be type-checked or tested, and what could not run is a failure, never a note (novox/hq -# issue 385); -# 2. every source that is not a test is type-checked with the toolchain's compiler and the flags a build -# compiles with — the same check the build makes, before the merge rather than in the walk; -# 3. its tests (test/*.test.ts) run under Node's own runner with types stripped, as its own `npm test` does -# after its build: the sources are compiled to dist/ and beside themselves first, so a test that imports -# the compiled module, or a source that imports its sibling by its .js name, finds it. -# -# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it — -# nothing of it could fail. A module whose tests exist and could not run fails (novox/hq issue 385). -# -# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads -# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same. -# -# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is -# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage -# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this -# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last -# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a -# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm -# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor -# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is -# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner). -set -eu - -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT - -deps="${TOOLCHAIN_MODULES:-/app/node_modules}" -if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then - origin=$(git remote get-url origin 2>/dev/null || true) - forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}" - owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}" - if [ -z "$forge" ] || [ -z "$owner" ]; then - echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)" - exit 1 - fi - echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)" - tc="$work/toolchain" - mkdir -p "$tc" - printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc" - # Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails. - if ! { node -e ' - const [base, dir] = process.argv.slice(1); - Promise.all(["package.json", "package-lock.json"].map(async (f) => { - const r = await fetch(base + f); - if (!r.ok) throw new Error(base + f + ": " + r.status); - require("fs").writeFileSync(dir + "/" + f, await r.text()); - })).catch((e) => { console.error(e.message); process.exit(1); });' \ - "$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" && - (cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" && - npm ci --ignore-scripts --no-audit --no-fund --loglevel=error && - npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then - echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)" - exit 1 - fi - deps="$tc/node_modules" -fi -tsc="$deps/typescript/bin/tsc" -if [ ! -f "$tsc" ]; then - echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain" - exit 1 -fi - -# The modules the change reaches: the planner's answer (hq ADR 0238), and every module a changed file is in — -# a change to a module's tests alone moves no build (hq ADR 0267), and its tests must still run. -touched=$({ - printf '%s\n' "${MESH_CHECK_MODULES:-}" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' - printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' -} | sed '/^$/d' | sort -u) - -failed="" -for m in $touched; do - d="modules/$m" - [ -f "$d/package.json" ] || continue - copy="$work/$m" - mkdir -p "$copy" - (cd "$d" && tar --exclude=./node_modules --exclude=./dist -cf - .) | (cd "$copy" && tar -xf -) - # What it depends on, against what the toolchain holds. - missing=$(node -e ' - const p = require(process.argv[1]); const have = require("fs").readdirSync(process.argv[2]); - const scoped = (n) => n.startsWith("@") ? n.split("/")[0] : n; - const names = Object.keys({ ...(p.dependencies || {}), ...(p.devDependencies || {}) }); - const held = (n) => have.includes(n) || (n.startsWith("@") && have.includes(scoped(n)) && - require("fs").existsSync(process.argv[2] + "/" + n)); - console.log(names.filter((n) => n !== "typescript" && !held(n)).join(" "));' "$copy/package.json" "$deps") - if [ -n "$missing" ]; then - echo "FAILED: $d could not be checked — it depends on $missing, which the toolchain does not hold and the check cannot fetch (novox/hq issue 385)" - failed="$failed $m" - continue - fi - mkdir -p "$copy/node_modules" - for p in "$deps"/*; do - case "$(basename "$p")" in typescript | esbuild | @esbuild) continue ;; esac - cp -r "$p" "$copy/node_modules/" - done - sources=$(cd "$copy" && find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*' | sort) - if [ -n "$sources" ]; then - echo "type-checking $d (TypeScript)" - # shellcheck disable=SC2086 # the module's sources, none with a space in its name - if ! (cd "$copy" && node "$tsc" --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 \ - --types node $sources); then - failed="$failed $m" - continue - fi - else - echo "$d has no TypeScript sources of its own to type-check: its tests read its files" - fi - if ! ls "$copy"/test/*.test.ts >/dev/null 2>&1; then - echo "NOT TESTED: $d is TypeScript and has no tests of its own" - continue - fi - if [ -n "$sources" ]; then - # shellcheck disable=SC2086 - (cd "$copy" && node "$tsc" --module NodeNext --moduleResolution NodeNext --target ES2022 --types node \ - --rootDir . --outDir dist $sources && node "$tsc" --module NodeNext --moduleResolution NodeNext \ - --target ES2022 --types node --rootDir . --outDir . $sources) - fi - echo "testing $d (TypeScript)" - if ! (cd "$copy" && node --test --test-reporter=spec --experimental-strip-types \ - --no-warnings=ExperimentalWarning test/*.test.ts); then - failed="$failed $m" - fi -done -if [ -n "$failed" ]; then - echo "TypeScript modules that failed:$failed" - exit 1 -fi diff --git a/merge-check.sh b/merge-check.sh deleted file mode 100755 index 834220ae..00000000 --- a/merge-check.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/bin/sh -# mesh-check-toolchain: go -# mesh-check-also: typescript merge-check-typescript.sh -# -# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge -# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain. -# -# The gate — the manifests the change touches through the running controller's module check, every machine -# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays -# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module -# graph builds these modules from here. It is not repeated here. This is the repository's own: -# -# 1. every manifest through the controller's module check, so one module's change cannot leave another -# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of -# long-running resources without `health` held to the number in health-undeclared, which only goes down; -# 2. the Go tests of every module the change touches that has them, under the race detector when the -# toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a -# pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose -# dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a -# test that could not run is a failure, never a note (novox/hq issue 385). The integration tests (`//go:build -# integration`) run here, with -tags integration, and only here. A module written in -# TypeScript is type-checked and tested by -# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311); -# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in -# retired-words, in any text a module's tools can show an agent — every string in its own code that is -# not a test, and every description in its manifest. Run over every module, not only the touched ones, -# so a word newly retired is found everywhere it stands. The check's own test runs first. -# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc -# that another module's manifest places — modules talk over the bus, not through each other's files. -# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks. -# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under -# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns -# that home and could swap a link in between. -# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could -# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names -# the nested ones the controller cannot protect alone yet, and only shrinks. -# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a -# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a -# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and -# every manifest against it are judged by the controller's module check in step 1. -# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or -# `${setting:}`, in its manifest or a template it names, or has a mergeable file naming no keys (which -# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks, -# judged against the trunk's copy at the merge base. -# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`) -# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list -# only shrinks, judged against the trunk's copy at the merge base. -# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it. -# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked): -# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out` -# only under a home; no module declares a path inside a directory of another module; and none is, lies in or -# holds a directory a system package owns under /var/lib (checks/owned/system, issue 1841). -# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned. -# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build. -# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory. -set -eu - -(cd checks/words && go test -count=1 . && go run . ../..) -# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in -# `particulars`, the covered modules in checks/particulars/covered. -(cd checks/particulars && go test -count=1 . && go run . ../..) -# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs. -(cd checks/toolchains && go test -count=1 . && go run . ../..) -# The image builder (novox/hq ADR 0487 §3): only modules/buildah provides rootless-image-build, docker never. -(cd checks/imagebuild && go test -count=1 . && go run . ../..) -# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory. -(cd checks/imageplace && go test -count=1 . && go run . ../..) -# The Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock. -(cd checks/locks && go test -count=1 . && go run . ../..) -# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it. -(cd checks/agenthome && go test -count=1 . && go run . ../..) -# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it. -(cd checks/mailstore && go test -count=1 . && go run . ../..) -# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so -# checks/sshdir stays until main runs checks/owned; it goes under issue 1035. -(cd checks/sshdir && go test -count=1 . && go run . ../..) -# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory; -# none in a system package's directory under /var/lib (issue 1841), the known exceptions only shrinking: judged -# against the trunk's copy of checks/owned/system-known at the merge base; a trunk without one is the list's first copy. -owned_base="" -if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then - if git cat-file -e "$merge_base:checks/owned/system-known" 2>/dev/null; then - owned_base=$(mktemp) - git show "$merge_base:checks/owned/system-known" >"$owned_base" - fi -else - echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/owned/system-known is held to the list it was first written with" -fi -(cd checks/owned && go test -count=1 . && if [ -n "$owned_base" ]; then go run . -base "$owned_base" ../..; else go run . ../..; fi) -# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The -# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused. -secrets_base="" -secrets_ref="origin/${MESH_CHECK_BASE:-main}" -if merge_base=$(git merge-base HEAD "$secrets_ref" 2>/dev/null); then - if git cat-file -e "$merge_base:checks/secrets/known" 2>/dev/null; then - secrets_base=$(mktemp) - git show "$merge_base:checks/secrets/known" >"$secrets_base" - fi -else - echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with" -fi -(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi) -# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's -# list at the merge base is what it is judged against; a trunk without the file had none. -predefined_base="" -if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then - predefined_base=$(mktemp) - if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then - echo '{}' >"$predefined_base" - fi -else - echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen" -fi -(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi) -# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved -# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy. -unmoved_base="" -if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then - if git cat-file -e "$merge_base:checks/config/unmoved" 2>/dev/null; then - unmoved_base=$(mktemp) - git show "$merge_base:checks/config/unmoved" >"$unmoved_base" - fi -else - echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with" -fi -(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi) -# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the -# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first. -inline_base="" -if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then - if git cat-file -e "$merge_base:checks/templates/inline" 2>/dev/null; then - inline_base=$(mktemp) - git show "$merge_base:checks/templates/inline" >"$inline_base" - fi -else - echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with" -fi -(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_base" ../..; else go run . ../..; fi) -# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are -# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in. -base_lists="" -base_ref="origin/${MESH_CHECK_BASE:-main}" -if git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null 2>&1 && - git cat-file -e "$base_ref:checks/paths/known" 2>/dev/null && git cat-file -e "$base_ref:checks/paths/shared" 2>/dev/null; then - base_lists=$(mktemp -d) - git show "$base_ref:checks/paths/known" >"$base_lists/known" - git show "$base_ref:checks/paths/shared" >"$base_lists/shared" -fi -(cd checks/paths && go test -count=1 . && if [ -n "$base_lists" ]; then go run . -base "$base_lists" ../..; else go run . ../..; fi) - -if [ -n "${MESH_GATE:-}" ]; then - checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; } - # **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how - # they are ready, as the controller counts them, against the number kept in health-undeclared. A change - # that raises it fails; one that lowers it writes the new number there, so it can never rise again. - kept=$(sed -n 's/^\([0-9][0-9]*\).*/\1/p' health-undeclared | head -n 1) - counted=$(printf '%s\n' "$checked" | sed -n 's/^long-running resources without health: \([0-9][0-9]*\)$/\1/p') - if [ -z "$counted" ]; then - echo "NOT COUNTED: the controller the mesh runs is older than the count of resources without health (ADR 0240 Phase B)" - elif [ "$counted" -gt "$kept" ]; then - echo "the long-running resources without health rose from $kept to $counted: declare how each new one is ready (ADR 0240 rule 8)" - exit 1 - elif [ "$counted" -lt "$kept" ]; then - echo "the long-running resources without health fell from $kept to $counted: write $counted in health-undeclared, so the count cannot rise again" - exit 1 - else - echo "long-running resources without health: $counted, as kept" - fi -else - echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one" -fi - -race="" -if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi - -# The modules the change reaches are the controller's planner's answer (MESH_CHECK_MODULES, hq ADR 0238): -# a module by its name, a new one by its directory. By hand, without it, the directories the changed files -# are in. -if [ -n "${MESH_CHECK_MODULES:-}" ]; then - touched=$(printf '%s\n' "$MESH_CHECK_MODULES" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' | sort -u) -else - touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u) -fi -# A module's dependencies: fetched, or a forge requirement taken from its clone beside the check -# (MESH_CHECK_BESIDE); its tests run against them (checks/deps, novox/hq issue 385). The helper's own test -# runs first. -(cd checks/deps && go test -count=1 .) -deps=$(mktemp -d) -trap 'rm -rf "$deps"' EXIT -(cd checks/deps && go build -o "$deps/deps" .) -untestable="" -for m in $touched; do - [ -d "modules/$m" ] || continue - if [ ! -f "modules/$m/go.mod" ]; then - [ -f "modules/$m/package.json" ] && echo "modules/$m is TypeScript: merge-check-typescript.sh type-checks and tests it" - continue - fi - if ! modfile=$(GOPRIVATE=git.novox.be "$deps/deps" -beside "${MESH_CHECK_BESIDE:-}" -work "$deps" "modules/$m"); then - echo "FAILED: modules/$m could not be tested — $modfile" - untestable="$untestable modules/$m" - continue - fi - [ -n "$modfile" ] && echo "modules/$m takes its forge dependencies from the clones beside the check" - echo "testing modules/$m" - unformatted=$(cd "modules/$m" && gofmt -l .) - if [ -n "$unformatted" ]; then - echo "modules/$m is not gofmt'd: $unformatted" - exit 1 - fi - cgo=0 - [ -n "$race" ] && cgo=1 - # The integration tests (a file whose first line is `//go:build integration`: a test that starts a bus, a store or - # another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07). - (cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... && - CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...) - # The docker tests of the bus's snapshot program and entrypoint (novox/hq ADR 0480 clause 6) start throwaway nats - # containers, and the Go toolchain container holds no docker, so above they skip with a "NOT RUN" line. Where the - # build seat says docker exists (MESH_CHECK_DOCKER=1) they run here by name, and a missing docker fails the check - # (MESH_REQUIRE_DOCKER=1 makes the tests fail instead of skip). Without it the step says it did not run. - if [ "$m" = nats ]; then - if [ "${MESH_CHECK_DOCKER:-}" = 1 ]; then - (cd modules/nats && MESH_REQUIRE_DOCKER=1 CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} -count=1 \ - -run 'TestTheDeclaredDumpRunsInTheBussImage|TestTheEntrypointReloadsTheServerWithoutDroppingAConnection|TestTheStoreSurvivesTheNewDeclarationTheOldAndTheNewAgain|TestASnapshotOfALiveBusRestoresToTheSameContent|TestTheSnapshotUserCannotChangeTheBus' \ - ./cmd/mesh-nats-snapshot) || { echo "the bus's docker tests failed"; exit 1; } - else - echo "NOT RUN: the bus's five docker tests (modules/nats/cmd/mesh-nats-snapshot) skipped: this check has no docker (MESH_CHECK_DOCKER is not 1)" - fi - fi -done -if [ -n "$untestable" ]; then - echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)" - exit 1 -fi diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index dbde6538..9f878b66 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -951,7 +951,7 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri return "", nil } // Said to a proposer waiting on it only once the discard is kept: what it reads next is already there (the - // flake of the router's refusal test under a loaded build agent, 2026-10-12). + // flake of the router's refusal test under a loaded build node, 2026-10-12). tell := func() { endedMu.Lock() ended[w.Ask] = w diff --git a/modules/dev-tools/README.md b/modules/dev-tools/README.md index 64eb8d9a..3bf50c27 100644 --- a/modules/dev-tools/README.md +++ b/modules/dev-tools/README.md @@ -19,8 +19,8 @@ Assign it to every node, with the `go` and `nodejs` modules: it requires their p | git and jq | `git`, `jq` | package | All are in the distribution's official repositories, so the node-engine's package manager installs them on any -node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a -pull request, and a local run is the developer's early answer, not the verdict. +node. The versions are the distribution's: the build modules' toolchains, which the checker runs a pull request's tests with, stay the +judge of a pull request, and a local run is the developer's early answer, not the verdict. Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock -file (`go.mod`, `package-lock.json`), as in the toolchain images. +file (`go.mod`, `package-lock.json`), as in the build modules. diff --git a/modules/gitea/.gitignore b/modules/gitea/.gitignore index b5e90585..03361903 100644 --- a/modules/gitea/.gitignore +++ b/modules/gitea/.gitignore @@ -1,2 +1,4 @@ # The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891). *.js +# The repositories program built by hand (go build ./cmd/repositories) is never committed. +/repositories diff --git a/modules/gitea/README.md b/modules/gitea/README.md index e735d7bb..4e51081c 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -22,8 +22,7 @@ Every new head of an open pull request is announced as `pull.updated`, once, and minutes, or when the search fails. - **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every open pull request from the forge itself, with the statuses its base requires and the ones its head has. The - delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's - `mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound + delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` with none of them for ten minutes since it first saw it so is a delivery held past its bound (`delivery..stalled`, state `unannounced`), which the controller raises like any other (issue 347). Each of the head's statuses comes with its state and when it was set, so a head whose check started and never answered — a required check pending, or never set beside one that is — is said as a stalled line in the state @@ -153,9 +152,8 @@ listed repository: - one that is not in the organisation and not under the setup account is made there, **empty and under the setup account**, where neither the tools' team nor the agents' team (both on every repository of the organisation) reaches it. Main gets a rule letting the setup login alone push; main's first commit is written with it: a README - naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit - tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on - a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges), + naming the description and nothing else (the checker runs a repository's tests by their language's convention, + ADR 0445, ADR 0529 §5). The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges), requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation. There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge list a user without access to the repository, and under the setup account `mesh-tools` has none; diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 6a2ba438..db1faaa8 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -699,9 +699,9 @@ export class GiteaClient { }; } - /** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the - * branch is edited — its required statuses replaced by these, everything else it says kept unless - * asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */ + /** Protect a branch (novox/hq ADR 0237): the rule named for the branch is edited — its required statuses taken + * off (ADR 0534 §5), everything else it says kept unless asked — or created, refusing direct pushes unless asked + * otherwise. Answers the rule as it is now. */ async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> { const had = await this.branchProtection(owner, repo, branch); const body = protectionBody(want, !had, branch); @@ -932,9 +932,9 @@ export class GiteaAdmin { return Array.isArray(res.body) ? res.body : []; } - /** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, and - * only the tools' account merges. The rule there is edited, its statuses left as they are; a repository with no - * rule for main gets one that requires none. Answers the repositories it touched. With the administrator's login: + /** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only + * the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no + * rule for main gets one. Answers the repositories it touched. With the administrator's login: * the tools' account is no repository's administrator, so no tool can do this (ADR 0365 §2). */ async ensureMainMerges(org: string): Promise { const repos: string[] = []; @@ -954,11 +954,11 @@ export class GiteaAdmin { if (patch.status !== 200) GiteaAdmin.fail(rule, patch); continue; } - // 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). + // A rule named exactly main is matched before any glob rule, so it would hide the approvals and administrator + // limit a glob rule covering main asks: they are copied into it (issue 948, review round 1). Its statuses are + // not: main requires none (ADR 0534 §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 = { enable_status_check: checks.length > 0, status_check_contexts: checks }; + const copied: Record = {}; 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; diff --git a/modules/gitea/cmd/repositories/check_test.go b/modules/gitea/cmd/repositories/check_test.go index 0a5dd7aa..84bf77d8 100644 --- a/modules/gitea/cmd/repositories/check_test.go +++ b/modules/gitea/cmd/repositories/check_test.go @@ -1,134 +1,135 @@ package main import ( + "os" + "path/filepath" "strings" "testing" ) -// The tests of issue 1301 (ADR 0447 §1, §5; ADR 0503 §3, §5; lead decisions L4 and L11): main requires `mesh/check` -// alone in place of `mesh/merge-gate` and `mesh/repo-check`; a main that requires `mesh/repo-check` without -// `mesh/merge-gate` (hq) keeps it; every repository of the organisation whose main requires `mesh/merge-gate` is -// switched, listed in the value or not; no other status is dropped. +// The tests of ADR 0534 §1, §2 (the operator, 2026-10-23): the forge requires no status to merge. Main's rule keeps no +// push, no push whitelist, the tools' account alone in its merge whitelist and no administrator merging past it, and +// requires no status: the program writes it so on every repository it lists, and takes every required status off a +// rule covering main in every repository of the organisation it does not list. Nothing else of an unlisted +// repository's rule is sent. func narrowedRule(contexts ...any) map[string]any { return map[string]any{ "enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{}, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools}, - "enable_status_check": true, "status_check_contexts": contexts, + "enable_status_check": len(contexts) > 0, "status_check_contexts": contexts, "block_admin_merge_override": true, } } -// 1301-S1: a listed repository whose main requires the two old statuses is put right to `mesh/check` in their place, -// every other status kept; a second pass changes nothing. -func TestListedRepositorySwapsTheTwoStatusesForCheck(t *testing.T) { +// requiresNone is whether a rule as the fake forge keeps it requires no status. +func requiresNone(rule map[string]any) bool { + return rule["enable_status_check"] != true && len(strs(rule["status_check_contexts"])) == 0 +} + +// 0534-1: a listed repository whose main requires statuses has them all taken off, the rest of the rule kept; a +// second pass changes nothing. +func TestListedRepositoryRequiresNoStatus(t *testing.T) { f := newFake() repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check", "other/x")}, "main") + checked := f.add("novox/checked", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main") s, _ := syncer(t, f) - o := run(t, s, Value{"mesh-delivery": {}}) - got := strs(repo.Rules[mainRule]["status_check_contexts"]) - if strings.Join(got, ",") != "other/x,mesh/check" { - t.Errorf("main requires %v, want other/x and mesh/check", got) - } - if r := result(o, "mesh-delivery"); r.Did != DidPutRight { - t.Errorf("outcome %+v, want put right", r) + o := run(t, s, Value{"mesh-delivery": {}, "checked": {}}) + for name, r := range map[string]*fRepo{"mesh-delivery": repo, "checked": checked} { + rule := r.Rules[mainRule] + if !requiresNone(rule) { + t.Errorf("%s's main still requires %v", name, rule["status_check_contexts"]) + } + if rule["enable_push"] != false || rule["enable_merge_whitelist"] != true || + strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools || rule["block_admin_merge_override"] != true { + t.Errorf("%s's main lost the rest of its rule: %v", name, rule) + } + if r := result(o, name); r.Did != DidPutRight { + t.Errorf("%s outcome %+v, want put right", name, r) + } } before := len(f.writes()) - run(t, s, Value{"mesh-delivery": {}}) + o = run(t, s, Value{"mesh-delivery": {}, "checked": {}}) if w := f.writes(); len(w) != before { t.Errorf("a second pass wrote %v", w[before:]) } + if result(o, "checked").Did != DidUnchanged { + t.Errorf("second outcome %+v, want unchanged", result(o, "checked")) + } } -// 1301-S1: a repository the program makes requires `mesh/check` alone from its first rule in the organisation on. -func TestNewRepositoryRequiresCheckAlone(t *testing.T) { +// 0534-1: a repository the program makes requires no status from its first rule in the organisation on. +func TestNewRepositoryRequiresNoStatus(t *testing.T) { f := newFake() s, _ := syncer(t, f) run(t, s, Value{"fresh": {Description: "d"}}) rule := f.repos["novox/fresh"].Rules[mainRule] - if got := strs(rule["status_check_contexts"]); rule["enable_status_check"] != true || strings.Join(got, ",") != CheckStatus { - t.Errorf("a new repository's main requires %v, want %s alone", got, CheckStatus) + if !requiresNone(rule) { + t.Errorf("a new repository's main requires %v, want none", rule["status_check_contexts"]) } - if readme := f.repos["novox/fresh"].Files["README.md"]; !strings.Contains(readme, CheckStatus) || - strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") { + if readme := f.repos["novox/fresh"].Files["README.md"]; strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") { t.Errorf("the README names the statuses of before: %q", readme) } } -// 1301-S1: mesh/check is never dropped, and a rule that requires it beside a retired status loses the retired one. -func TestCheckIsKeptAndARetiredStatusBesideItGoes(t *testing.T) { +// 0534-1: a listed repository whose main requires nothing stays so, and is not written when the rest is right. +func TestListedRuleRequiringNothingIsRight(t *testing.T) { f := newFake() - both := f.add("novox/both", map[string]map[string]any{mainRule: narrowedRule("mesh/check", "mesh/repo-check")}, "main") - right := f.add("novox/right", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main") + none := f.add("novox/none", map[string]map[string]any{mainRule: narrowedRule()}, "main") s, _ := syncer(t, f) - o := run(t, s, Value{"both": {}, "right": {}}) - if got := strs(both.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { - t.Errorf("both requires %v, want %s alone", got, CheckStatus) + o := run(t, s, Value{"none": {}}) + if !requiresNone(none.Rules[mainRule]) { + t.Errorf("none requires %v", none.Rules[mainRule]["status_check_contexts"]) } - if got := strs(right.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { - t.Errorf("right requires %v", got) + if result(o, "none").Did != DidUnchanged { + t.Errorf("outcome %+v, want unchanged", result(o, "none")) } - if result(o, "right").Did != DidUnchanged || result(o, "both").Did != DidPutRight { - t.Errorf("outcome %+v", o) + for _, w := range f.writes() { + t.Errorf("a right rule was written: %s", w) } } -// 1301-S1: a listed repository whose main requires nothing is given mesh/check, not the old two. -func TestListedRuleRequiringNothingGetsCheck(t *testing.T) { - f := newFake() - none := f.add("novox/none", map[string]map[string]any{mainRule: {"enable_push": false}}, "main") - s, _ := syncer(t, f) - run(t, s, Value{"none": {}}) - if got := strs(none.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { - t.Errorf("none requires %v, want %s", got, CheckStatus) - } -} - -// 1301-S2 (ADR 0503 §5): a main that requires mesh/repo-check without mesh/merge-gate is judged by its own script -// alone (hq): it keeps mesh/repo-check and is not given mesh/check, listed or not. -func TestOwnCheckAloneIsKept(t *testing.T) { +// 0534-1: hq, whose main required mesh/repo-check alone, requires no status either, listed or not. +func TestOwnCheckAloneGoesToo(t *testing.T) { f := newFake() hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main") lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main") s, _ := syncer(t, f) o := run(t, s, Value{"hq": {}}) for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} { - if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != "mesh/repo-check" { - t.Errorf("%s requires %v, want mesh/repo-check alone", name, got) + if !requiresNone(repo.Rules[mainRule]) { + t.Errorf("%s requires %v, want none", name, repo.Rules[mainRule]["status_check_contexts"]) + } + if r := result(o, name); r.Did != DidPutRight { + t.Errorf("%s outcome %+v, want put right", name, r) } - } - if result(o, "hq").Did != DidUnchanged { - t.Errorf("hq outcome %+v, want unchanged", result(o, "hq")) - } - for _, w := range f.writes() { - t.Errorf("a repository judged by its own check alone was written: %s", w) } } -// 1301-S3 (L4, L11): a repository the value does not list whose main requires mesh/merge-gate has the two old -// statuses swapped for mesh/check, and only its statuses: nothing else of its rule is sent. One that requires -// neither, or mesh/repo-check alone, is not written to. -func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) { +// 0534-1: a repository the value does not list whose rule covering main requires any status has them taken off, and +// only its statuses: nothing else of its rule is sent. One that requires none is not written to. +func TestUnlistedRepositoryRequiringAStatusHasItTakenOff(t *testing.T) { f := newFake() catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: { - "enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check"}, + "enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/check"}, }}, "main") toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main") - f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main") + f.add("novox/unticked", map[string]map[string]any{mainRule: {"enable_push": false, "enable_status_check": false, + "status_check_contexts": []any{"mesh/check"}}}, "main") f.add("novox/norule", nil, "main") f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") s, _ := syncer(t, f) o := run(t, s, Value{}) - if got := strs(catalog.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { - t.Errorf("mesh-catalog requires %v, want %s", got, CheckStatus) + if !requiresNone(catalog.Rules[mainRule]) { + t.Errorf("mesh-catalog requires %v, want none", catalog.Rules[mainRule]["status_check_contexts"]) } if catalog.Rules[mainRule]["enable_push"] != true { - t.Errorf("an unlisted repository's rule was narrowed beyond its statuses: %v", catalog.Rules[mainRule]) + t.Errorf("an unlisted repository's rule was changed beyond its statuses: %v", catalog.Rules[mainRule]) } - if got := strs(toolsGo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { - t.Errorf("mesh-tools-go requires %v, want %s", got, CheckStatus) + if !requiresNone(toolsGo.Rules[mainRule]) { + t.Errorf("mesh-tools-go requires %v, want none", toolsGo.Rules[mainRule]["status_check_contexts"]) } want := []string{ "PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main", @@ -142,7 +143,7 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) { t.Errorf("%s outcome %+v, want put right", n, r) } } - for _, n := range []string{"plain", "hq", "norule", "elsewhere"} { + for _, n := range []string{"plain", "unticked", "norule", "elsewhere"} { if r := result(o, n); r.Name != "" { t.Errorf("%s is in the outcome though nothing was done: %+v", n, r) } @@ -154,27 +155,27 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) { } } -// 1301-S3: the organisation's repositories are read page by page, so one past the first page is switched too. +// 0534-1: the organisation's repositories are read page by page, so one past the first page is put right too. func TestUnlistedRepositoriesAreReadPastTheFirstPage(t *testing.T) { f := newFake() f.pageSize = 2 for _, n := range []string{"a", "b", "c", "d", "e"} { - f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check")}, "main") + f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main") } s, _ := syncer(t, f) run(t, s, Value{}) for _, n := range []string{"a", "b", "c", "d", "e"} { - if got := strs(f.repos["novox/"+n].Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus { - t.Errorf("%s requires %v", n, got) + if rule := f.repos["novox/"+n].Rules[mainRule]; !requiresNone(rule) { + t.Errorf("%s requires %v", n, rule["status_check_contexts"]) } } } -// 1301-S3: a refused swap is said with the repository and its step, and the pass goes on to the next. -func TestRefusedSwapIsSaid(t *testing.T) { +// 0534-1: a refused removal is said with the repository and its step, and the pass goes on to the next. +func TestRefusedRemovalIsSaid(t *testing.T) { f := newFake() - f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") - f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main") + f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main") + f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main") f.refuse = func(method, path string) (int, string) { if method == "PATCH" && strings.Contains(path, "/novox/a/") { return 403, "no" @@ -183,8 +184,8 @@ func TestRefusedSwapIsSaid(t *testing.T) { } s, _ := syncer(t, f) o := run(t, s, Value{}) - if r := result(o, "a"); r.Did != DidRefused || r.Step != StepSwap { - t.Errorf("a: %+v, want refused at %s", r, StepSwap) + if r := result(o, "a"); r.Did != DidRefused || r.Step != StepNoStatus { + t.Errorf("a: %+v, want refused at %s", r, StepNoStatus) } if r := result(o, "b"); r.Did != DidPutRight { t.Errorf("b: %+v, want put right", r) @@ -213,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) + } + } +} diff --git a/modules/gitea/cmd/repositories/fake_test.go b/modules/gitea/cmd/repositories/fake_test.go index 79962270..c3746912 100644 --- a/modules/gitea/cmd/repositories/fake_test.go +++ b/modules/gitea/cmd/repositories/fake_test.go @@ -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 diff --git a/modules/gitea/cmd/repositories/first.go b/modules/gitea/cmd/repositories/first.go index 1ae7365a..61abc30e 100644 --- a/modules/gitea/cmd/repositories/first.go +++ b/modules/gitea/cmd/repositories/first.go @@ -2,34 +2,9 @@ package main import "strings" -// MergeCheck is the first commit's merge-check.sh (ADR 0428 §4): it declares the Go toolchain (ADR 0238 §4, read -// among a script's first lines by the build seat), and runs the unit tests, then with -tags integration the -// integration tests, of every Go module in the tree; on a tree with none it says so and passes. -const MergeCheck = `#!/bin/sh -# mesh-check-toolchain: go -# -# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4): -# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration). -# A tree with no Go module has nothing to test, and the check says so and passes. -set -eu -cd "$(dirname "$0")" - -modules=$(find . -name go.mod -not -path '*/vendor/*' -not -path './.git/*' | sort) -if [ -z "$modules" ]; then - echo "This tree holds no Go module, so there are no tests to run; the check passes." - exit 0 -fi -for mod in $modules; do - dir=$(dirname "$mod") - echo "== $dir: go vet and the unit tests" - (cd "$dir" && go vet ./... && go test ./...) - echo "== $dir: the integration tests (-tags integration)" - (cd "$dir" && go vet -tags integration ./... && go test -tags integration ./...) -done -` - // FirstCommit is main's first commit of a listed repository: its README, naming the repository's purpose, and -// its own check. +// nothing else: the checker runs a repository's tests by their language's convention (ADR 0445), so no script is written +// (ADR 0529 §5). func FirstCommit(name string, r Repo) map[string]string { purpose := strings.TrimSpace(r.Description) if purpose == "" { @@ -38,6 +13,6 @@ func FirstCommit(name string, r Repo) map[string]string { readme := "# " + name + "\n\n" + purpose + "\n\n" + "Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " + "a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " + - "main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n" - return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck} + "main, its tests run by their language's convention (novox/hq ADR 0447, ADR 0445).\n" + return map[string]string{"README.md": readme} } diff --git a/modules/gitea/cmd/repositories/main.go b/modules/gitea/cmd/repositories/main.go index fabb3d65..fea644bc 100644 --- a/modules/gitea/cmd/repositories/main.go +++ b/modules/gitea/cmd/repositories/main.go @@ -3,8 +3,8 @@ // again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login, // makes every listed repository that does not exist under that login's own account, protects its main, writes // main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has -// its main rule put right, requiring mesh/check in place of the two statuses it replaces (issue 1301). A repository -// the value does not list is touched only to swap those two for mesh/check when its main requires mesh/merge-gate. It keeps what it did, per +// its main rule put right, requiring no status (novox/hq ADR 0534 §1). A repository the value does not list is +// touched only to take the required statuses off a rule covering its main. It keeps what it did, per // repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a // step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once // step (ADR 0136) reports it and runs it again on every apply until it passes. @@ -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) } @@ -57,6 +58,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 diff --git a/modules/gitea/cmd/repositories/manifest_test.go b/modules/gitea/cmd/repositories/manifest_test.go index 75417e23..76c8619a 100644 --- a/modules/gitea/cmd/repositories/manifest_test.go +++ b/modules/gitea/cmd/repositories/manifest_test.go @@ -111,3 +111,61 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) { t.Errorf("repositories is declared unlike agent-read-teams: %v vs %v", v, teams) } } + +// 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 { + return true + } + } + return false +} diff --git a/modules/gitea/cmd/repositories/override_test.go b/modules/gitea/cmd/repositories/override_test.go index 8dc5d7bc..ea184d51 100644 --- a/modules/gitea/cmd/repositories/override_test.go +++ b/modules/gitea/cmd/repositories/override_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past a status refused, +// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past it refused, // as every repository's main has had since 2026-10-07. func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) { f := newFake() @@ -18,7 +18,7 @@ func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) { } // 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its -// statuses kept (the two old ones as mesh/check, issue 1301), and a second pass changes nothing. +// statuses taken off (ADR 0534 §1), and a second pass changes nothing. func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) { f := newFake() repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: { @@ -33,8 +33,8 @@ func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) { if rule["block_admin_merge_override"] != true { t.Errorf("the administrator's override is still allowed: %v", rule) } - if c := strs(rule["status_check_contexts"]); strings.Join(c, ",") != "other/x,mesh/check" { - t.Errorf("statuses %v, want other/x kept and mesh/check for the two it replaces (issue 1301)", c) + if !requiresNone(rule) { + t.Errorf("statuses %v, want none (ADR 0534 §1)", rule["status_check_contexts"]) } if r := result(o, "mesh-delivery"); r.Did != DidPutRight { t.Errorf("outcome %+v, want put right", r) diff --git a/modules/gitea/cmd/repositories/repositories.go b/modules/gitea/cmd/repositories/repositories.go index 3e565cbe..8a7eb68d 100644 --- a/modules/gitea/cmd/repositories/repositories.go +++ b/modules/gitea/cmd/repositories/repositories.go @@ -11,6 +11,7 @@ import ( "os" "path" "regexp" + "slices" "sort" "strings" "time" @@ -26,46 +27,11 @@ const ( MainBranch = "main" ) -// CheckStatus is the one status main requires (ADR 0447 §1, issue 1301): the checker's verdict on a pull request's -// head merged onto its trunk's head. It replaces GateStatus and OwnCheckStatus, and is never dropped. -const CheckStatus = "mesh/check" - -// GateStatus and OwnCheckStatus are the two statuses CheckStatus replaces: the controller's merge gate and the -// repository's own merge-check.sh (ADR 0238 §4, ADR 0428 §5 before ADR 0447 §1). -const ( - GateStatus = "mesh/merge-gate" - OwnCheckStatus = "mesh/repo-check" -) - -// wanted is what a main rule requiring have should require: have with GateStatus and OwnCheckStatus replaced by -// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus with neither -// GateStatus nor CheckStatus is judged by its own script alone, as hq is until issue 1296 settles how hq is -// checked (ADR 0503 §5): the checker writes no status there (ADR 0484 §8), so it is left as it is. This reads the -// rule, not a list of names (lead decision L11 of R4e). -func wanted(have []string) []string { - if !has(have, GateStatus) && !has(have, CheckStatus) && has(have, OwnCheckStatus) { - return have - } - out := []string{} - for _, s := range have { - if s != GateStatus && s != OwnCheckStatus && s != CheckStatus { - out = append(out, s) - } - } - return append(out, CheckStatus) -} - -func has(list []string, want string) bool { - for _, s := range list { - if s == want { - return true - } - } - return false -} - -func sameList(a, b []string) bool { - return strings.Join(a, "\x00") == strings.Join(b, "\x00") +// noStatus is what main's rule says of statuses: none is required to merge (novox/hq ADR 0534 §1, the operator, +// 2026-10-23). The checks still run and report; mesh-delivery asks the operator's Approve on a passing verdict and the +// merge tool judges it at the merge (ADR 0534 §3, §4). The forge enforces none of them. +func noStatus() map[string]any { + return map[string]any{"enable_status_check": false, "status_check_contexts": []string{}} } // The steps of one repository, as an outcome names a refused one (ADR 0428 §6). @@ -82,8 +48,8 @@ const ( StepRuleAfterMove = "rule-after-move" // StepList reads the organisation's repositories, for those the value does not list (issue 1301). StepList = "list" - // StepSwap swaps the two old statuses for CheckStatus on a repository the value does not list (issue 1301). - StepSwap = "swap" + // StepNoStatus takes the required statuses off main's rule of a repository the value does not list (ADR 0534 §1). + StepNoStatus = "no-status" ) // What a pass did with one repository. @@ -104,7 +70,7 @@ type Repo struct { } // 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" +const LeftAsSet = "main's rules are left as set at the forge (keep-main-rules is false): no existing repository's protection was changed but that a required status was taken away (ADR 0534 §1)" // ReadKeepMainRules reads the setting keep-main-rules from the file the mesh renders; absent or not true is false. func ReadKeepMainRules(file string) bool { @@ -130,6 +96,37 @@ func ReadKeepMainRules(file string) bool { // 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) { @@ -192,10 +189,13 @@ 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 - // 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. + 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 + // KeepMainRules is the setting keep-main-rules: only when true does a pass put right the whole main rule of a repository + // that already exists (no push, the merge whitelist). False (the default) leaves every existing repository's protection as + // people set it at the forge, except that a required status is taken away once (ADR 0534 §1), and makes new repositories. KeepMainRules bool Log func(format string, a ...any) // WaitFor is how long to wait for the forge to answer; none when zero. @@ -232,13 +232,12 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) { 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) } else { - s.say("%s: %s/%s (%s in place of %s and %s)", r.Did, s.Org, r.Name, CheckStatus, GateStatus, OwnCheckStatus) + s.say("%s: %s/%s (main requires no status)", r.Did, s.Org, r.Name) } out.Repositories = append(out.Repositories, r) } @@ -248,12 +247,10 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) { // orgPage is how many repositories one read of the organisation's list asks for. const orgPage = 50 -// switchUnlisted swaps the two old statuses for CheckStatus on main's rule of every repository of the organisation -// the value does not list whose main requires GateStatus (issue 1301, lead decisions L4 and L11 of R4e): the -// repositories the merge gate judges are switched in the delivery that has the checker write mesh/check (ADR 0447 -// §5), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the -// value does not hold is changed (ADR 0428 §7 for all of it but the statuses the mesh itself requires). A main that -// does not require GateStatus gets no call. It answers only what it did: a repository switched, or refused. +// switchUnlisted takes every required status off a rule covering main in every repository of the organisation the +// value does not list (ADR 0534 §1): the forge requires no status to merge anywhere, with no list kept by hand. Only +// the statuses are sent; nothing else of a rule the value does not hold is changed (ADR 0428 §7). A main whose rules +// require no status gets no call. It answers only what it did: a repository put right, or refused. func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result { var names []string for page := 1; ; page++ { @@ -282,16 +279,11 @@ func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result { continue } var did *Result - for _, r := range rules { - if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) { - continue - } - body := map[string]any{"enable_status_check": true, "status_check_contexts": wanted(required(r))} - if err := s.Forge.SendJSON(ctx, http.MethodPatch, - repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), body, nil); err != nil { - did = &Result{Name: n, Did: DidRefused, Step: StepSwap, Said: err.Error()} - break - } + changed, err := s.stripStatuses(ctx, s.Org, n, rules) + switch { + case err != nil: + did = &Result{Name: n, Did: DidRefused, Step: StepNoStatus, Said: err.Error()} + case changed: did = &Result{Name: n, Did: DidPutRight} } if did != nil { @@ -336,12 +328,41 @@ func (s *Syncer) exists(ctx context.Context, owner, name string) (bool, error) { // one is one listed repository (ADR 0428 §2, §3): put right when it is in the organisation; else made, or finished // when found under the setup account, and moved in only once main's rule is narrowed. A refused step stops it. +// stripStatuses takes every required status off each rule covering main of one repository, and nothing else of the rule (ADR +// 0534 §1): the one thing a pass does to an existing repository when keep-main-rules is false. A repository whose rules require +// none gets no call, so the statuses are taken away once. It answers whether it changed anything. +func (s *Syncer) stripStatuses(ctx context.Context, owner, name string, rules []rule) (bool, error) { + changed := false + for _, r := range rules { + if ok, _ := path.Match(ruleName(r), MainBranch); !ok || len(required(r)) == 0 { + continue + } + if err := s.Forge.SendJSON(ctx, http.MethodPatch, + repoAPI(owner, name)+"/branch_protections/"+url.PathEscape(ruleName(r)), noStatus(), nil); err != nil { + return changed, err + } + changed = true + } + return changed, nil +} + func (s *Syncer) one(ctx context.Context, name string, r Repo) Result { inOrg, err := s.exists(ctx, s.Org, name) if err != nil { return refused(name, StepRead, err) } if inOrg && !s.KeepMainRules { + rules, err := s.rules(ctx, s.Org, name) + if err != nil { + return refused(name, StepRead, err) + } + changed, err := s.stripStatuses(ctx, s.Org, name, rules) + if err != nil { + return refused(name, StepNoStatus, err) + } + if changed { + return Result{Name: name, Did: DidPutRight} + } return Result{Name: name, Did: DidUnchanged} } if inOrg { @@ -387,9 +408,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(rule, s.Tools), nil); err != nil { + narrowed(s.Tools, s.Mergers), nil); err != nil { return refused(name, StepNarrow, err) } } @@ -453,24 +474,6 @@ func strList(v any) []string { return out } -// statuses is what the rules require together, as wanted makes it. -func statuses(rules ...rule) []string { - seen := map[string]bool{} - var out []string - add := func(s string) { - if !seen[s] { - seen[s] = true - out = append(out, s) - } - } - for _, r := range rules { - for _, s := range required(r) { - add(s) - } - } - return wanted(out) -} - // required is what a rule requires: its statuses when it checks them, else none. func required(r rule) []string { if r == nil || r["enable_status_check"] != true { @@ -479,46 +482,58 @@ func required(r rule) []string { return strList(r["status_check_contexts"]) } -// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, -// requiring the statuses (ADR 0428 §5), and no administrator merging past them, as every repository's main has been -// since 2026-10-07 (issue 1141). A field the forge keeps beside these is left as it is. -func narrowed(r rule, toolsAccount string, also ...rule) map[string]any { - return map[string]any{ +// 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, 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_status_check": true, "status_check_contexts": statuses(append([]rule{r}, also...)...), + "enable_merge_whitelist": true, "merge_whitelist_usernames": whitelist(toolsAccount, mergers), "block_admin_merge_override": true, } + for k, v := range noStatus() { + body[k] = v + } + return body } -// isRight says whether a rule already is narrowed and requires what wanted makes of it. -func isRight(r rule, toolsAccount string) bool { +// 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, 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 } - have := strList(r["status_check_contexts"]) - return r["enable_status_check"] == true && len(have) > 0 && sameList(have, wanted(have)) && + return r["enable_status_check"] != true && len(strList(r["status_check_contexts"])) == 0 && r["block_admin_merge_override"] == true } -// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires but -// the two CheckStatus replaces (ADR 0447 §1). -// A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts -// ensureMainMerges, issue 948). It takes away no other status and never unprotects (ADR 0428 §5, §7). +// putRight makes main's rule in an organisation's repository the narrowed one, requiring no status (ADR 0534 §1). +// A repository with no rule named main gets one, carrying the approvals a glob rule covering main asks (client.ts +// ensureMainMerges, issue 948). It never unprotects (ADR 0428 §7). func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) { rules, err := s.rules(ctx, owner, name) if err != nil { 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(own, s.Tools), nil) + narrowed(s.Tools, s.Mergers), nil) } var covering []rule for _, r := range rules { @@ -526,7 +541,7 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) covering = append(covering, r) } } - body := narrowed(nil, s.Tools, covering...) + body := narrowed(s.Tools, s.Mergers) body["rule_name"] = MainBranch approvals := 0.0 for _, r := range covering { diff --git a/modules/gitea/cmd/repositories/repositories_test.go b/modules/gitea/cmd/repositories/repositories_test.go index b97cd061..13689a9a 100644 --- a/modules/gitea/cmd/repositories/repositories_test.go +++ b/modules/gitea/cmd/repositories/repositories_test.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -19,7 +18,7 @@ const mainRule = "main" // 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that // login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/check (1124/AC5 // as issue 1301 changes it), and only then moved into the organisation. Nothing reaches the organisation before the -// narrowed rule, and nothing else is pushed to main. +// narrowed rule, and nothing else is pushed to main. Main requires no status (ADR 0534 §1). func TestNewRepositoryIsMadeInOrder(t *testing.T) { f := newFake() s, _ := syncer(t, f) @@ -53,7 +52,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) { if repo.Private || repo.Description != "the mesh's delivery" { t.Errorf("made as %+v, want public with its description", repo) } - if repo.Commits != 1 || len(repo.Files) != 2 || repo.Files["README.md"] == "" || repo.Files["merge-check.sh"] == "" { + if repo.Commits != 1 || len(repo.Files) != 1 || repo.Files["README.md"] == "" { t.Errorf("first commit: %d commits, files %v", repo.Commits, keysOf(repo.Files)) } if !strings.Contains(repo.Files["README.md"], "the mesh's delivery") { @@ -66,9 +65,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) { if rule["enable_merge_whitelist"] != true || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools { t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule) } - checks := strs(rule["status_check_contexts"]) - if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus { - t.Errorf("main does not require mesh/check alone (1124/AC5, issue 1301): %v", rule) + if !requiresNone(rule) { + t.Errorf("main requires a status (ADR 0534 §1): %v", rule) } if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" { t.Errorf("outcome %+v, want made", r) @@ -87,7 +85,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) { // 1124/AC2: the forge drops from a merge whitelist a user without access to the repository, and under the setup // account the tools' account has none; so once the repository is in the organisation its main rule is read back and -// put right: the tools' account the only one that merges, mesh/check required. +// put right: the tools' account the only one that merges, no status required (ADR 0534 §1). func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) { f := newFake() s, _ := syncer(t, f) @@ -103,9 +101,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) { if rule["enable_push"] != false { t.Errorf("after the move main takes a push: %v", rule) } - checks := strs(rule["status_check_contexts"]) - if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus { - t.Errorf("after the move main does not require mesh/check alone: %v", rule) + if !requiresNone(rule) { + t.Errorf("after the move main requires a status (ADR 0534 §1): %v", rule) } if r := result(o, "mesh-delivery"); r.Did != DidMade { t.Errorf("outcome %+v, want made", r) @@ -136,8 +133,7 @@ func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) { } // 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right; -// a rule still letting the setup login push is narrowed, its statuses kept but the two mesh/check replaces (1124/AC5, -// issue 1301). +// a rule still letting the setup login push is narrowed, and requires no status (ADR 0534 §1). func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) { f := newFake() hq := f.add("novox/hq", map[string]map[string]any{mainRule: { @@ -157,12 +153,11 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) { if rule["enable_push"] != false || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools { t.Errorf("hq's rule not narrowed: %v", rule) } - checks := strs(rule["status_check_contexts"]) - if strings.Join(checks, ",") != "other/x,mesh/check" { - t.Errorf("hq's rule requires %v, want other/x and mesh/check", checks) + if !requiresNone(rule) { + t.Errorf("hq's rule requires %v, want none (ADR 0534 §1)", rule["status_check_contexts"]) } made := bare.Rules[mainRule] - if made == nil || made["enable_push"] != false || strings.Join(strs(made["status_check_contexts"]), ",") != CheckStatus { + if made == nil || made["enable_push"] != false || !requiresNone(made) { t.Errorf("bare's rule: %v", made) } if hq.Commits != 0 || bare.Commits != 0 { @@ -212,33 +207,33 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) { } } -// 1124/AC5 as issue 1301 changes it: putting a rule right adds mesh/check when the rule requires mesh/merge-gate, -// and keeps every other status there; a rule requiring mesh/repo-check alone keeps it (ADR 0503 §5). -func TestStatusesAreAddedAndNeverDropped(t *testing.T) { +// ADR 0534 §1: putting a rule right takes every required status off it, whichever they are, and a rule already right +// is not written. +func TestStatusesAreTakenOff(t *testing.T) { f := newFake() only := f.add("novox/only", map[string]map[string]any{mainRule: { "enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools}, "enable_status_check": true, "status_check_contexts": []any{"mesh/repo-check"}, }}, "main") - more := f.add("novox/more", map[string]map[string]any{mainRule: { + right := f.add("novox/right", map[string]map[string]any{mainRule: { "enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools}, - "enable_status_check": true, "status_check_contexts": []any{"mesh/delivery", "mesh/check"}, + "enable_status_check": false, "status_check_contexts": []any{}, "block_admin_merge_override": true, // right since issue 1141 }}, "main") s, _ := syncer(t, f) - o := run(t, s, Value{"only": {}, "more": {}}) - if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != "mesh/repo-check" { - t.Errorf("only: %v", c) + o := run(t, s, Value{"only": {}, "right": {}}) + if !requiresNone(only.Rules[mainRule]) { + t.Errorf("only: %v", only.Rules[mainRule]["status_check_contexts"]) } - if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") { - t.Errorf("more lost a status: %v", c) + if !requiresNone(right.Rules[mainRule]) { + t.Errorf("right: %v", right.Rules[mainRule]["status_check_contexts"]) } for _, w := range f.writes() { - if strings.Contains(w, "/more/") { + if strings.Contains(w, "/right/") { t.Errorf("a right rule was written: %s", w) } } - if result(o, "more").Did != DidUnchanged || result(o, "only").Did != DidPutRight { + if result(o, "right").Did != DidUnchanged || result(o, "only").Did != DidPutRight { t.Errorf("outcome %+v", o) } } @@ -409,66 +404,19 @@ func TestReadValue(t *testing.T) { } } -// 1124/AC4: the first commit's merge-check.sh declares its toolchain in its first lines, runs the tests of a Go -// module in the tree, and on a tree with none says so and passes. -func TestFirstCommitMergeCheck(t *testing.T) { +// 1296/E1b (ADR 0527 §2, ADR 0529 §5): the first commit holds a README naming the purpose and no merge-check.sh: the +// checker runs a repository's tests by their language's convention and no script is written for it. +func TestFirstCommitHasNoMergeCheck(t *testing.T) { files := FirstCommit("mesh-delivery", Repo{Description: "the mesh's delivery"}) - script := files["merge-check.sh"] - lines := strings.SplitN(script, "\n", 4) - if lines[0] != "#!/bin/sh" || lines[1] != "# mesh-check-toolchain: go" { - t.Fatalf("first lines %q", lines[:2]) + if _, has := files["merge-check.sh"]; has || len(files) != 1 { + t.Fatalf("first commit files %v, want the README alone", keysOf(files)) } if !strings.Contains(files["README.md"], "mesh-delivery") || !strings.Contains(files["README.md"], "the mesh's delivery") { t.Errorf("README %q", files["README.md"]) } - goBin, err := exec.LookPath("go") - if err != nil { - t.Skip("no go on PATH to run the script with") + if strings.Contains(files["README.md"], "merge-check") { + t.Errorf("the README names a merge-check.sh: %q", files["README.md"]) } - - tree := t.TempDir() - mod := filepath.Join(tree, "sub") - _ = os.MkdirAll(mod, 0o755) - _ = os.WriteFile(filepath.Join(tree, "merge-check.sh"), []byte(script), 0o755) - _ = os.WriteFile(filepath.Join(mod, "go.mod"), []byte("module tiny\n\ngo 1.21\n"), 0o644) - _ = os.WriteFile(filepath.Join(mod, "tiny_test.go"), []byte(`package tiny - -import ( - "os" - "testing" -) - -func TestRan(t *testing.T) { _ = os.WriteFile("ran", []byte("yes"), 0o644) } -`), 0o644) - cmd := exec.Command("sh", "merge-check.sh") - cmd.Dir = tree - cmd.Env = append(os.Environ(), "GOFLAGS=", "GOTOOLCHAIN=local", "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH")) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("on a tree with a Go module: %v\n%s", err, out) - } - if _, err := os.Stat(filepath.Join(mod, "ran")); err != nil || !strings.Contains(string(out), "integration") { - t.Fatalf("its tests did not run, or not with -tags integration:\n%s", out) - } - - // A failing test fails the check. - _ = os.WriteFile(filepath.Join(mod, "fail_test.go"), []byte("package tiny\n\nimport \"testing\"\n\nfunc TestFails(t *testing.T) { t.Fatal(\"no\") }\n"), 0o644) - if out, err := cmd2(tree, cmd.Env).CombinedOutput(); err == nil { - t.Fatalf("a failing test passed the check:\n%s", out) - } - - empty := t.TempDir() - _ = os.WriteFile(filepath.Join(empty, "merge-check.sh"), []byte(script), 0o755) - out, err = cmd2(empty, cmd.Env).CombinedOutput() - if err != nil || !strings.Contains(string(out), "no Go module") { - t.Fatalf("on an empty tree: %v\n%s", err, out) - } -} - -func cmd2(dir string, env []string) *exec.Cmd { - c := exec.Command("sh", "merge-check.sh") - c.Dir, c.Env = dir, env - return c } func keysOf(m map[string]string) []string { @@ -479,37 +427,53 @@ 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) { +// The operator, 2026-10-23: "disable the gitea branch protection reset thing". With keep-main-rules false (the default) a +// pass makes new repositories and changes nothing of an existing repository's protection but taking away a required status, +// once (ADR 0534 §1): the operator never has to click it off, and what people set at the forge stays. +func TestKeepMainRulesFalseTakesAwayRequiredStatusesOnceAndChangesNothingElse(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"}, + "enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/repo-check"}, }}, "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"}, + "enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/check"}, }}, "main") + plain := f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": true}}, "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) + for _, r := range []*fRepo{hq, old} { + if got := required(r.Rules[mainRule]); len(got) != 0 { + t.Errorf("a required status stands: %v", got) + } + if r.Rules[mainRule]["enable_push"] != true { + t.Error("a pass with keep-main-rules false changed more of the rule than its statuses") } } - if hq.Rules[mainRule]["enable_push"] != true || old.Rules[mainRule]["enable_push"] != true { - t.Error("an existing repository's rule was changed") + if plain.Rules[mainRule]["enable_push"] != true { + t.Error("a repository requiring no status was changed") + } + for _, c := range f.writes() { + if strings.Contains(c, "/novox/plain") || strings.Contains(c, "/repos/novox/plain") { + t.Errorf("a repository requiring no status was written: %s", c) + } } if f.repos["novox/fresh"] == nil { t.Error("the new repository was not made") } - if r := result(o, "hq"); r.Did != DidUnchanged { + if r := result(o, "hq"); r.Did != DidPutRight { 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) } + // Once: a second pass writes nothing. + before := len(f.writes()) + run(t, s, Value{"hq": {}, "fresh": {}}) + if w := f.writes(); len(w) != before { + t.Errorf("a second pass wrote %v", w[before:]) + } } func TestReadKeepMainRules(t *testing.T) { diff --git a/modules/gitea/files/config.json b/modules/gitea/files/config.json index 0967ef42..7ee627f8 100644 --- a/modules/gitea/files/config.json +++ b/modules/gitea/files/config.json @@ -1 +1 @@ -{} +{"main-mergers": ["jschoubben"]} diff --git a/modules/gitea/go.mod b/modules/gitea/go.mod index 99c83de0..6639c1c3 100644 --- a/modules/gitea/go.mod +++ b/modules/gitea/go.mod @@ -3,7 +3,7 @@ module gitea go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/gitea/go.sum b/modules/gitea/go.sum index db1ea18c..2a3e9f34 100644 --- a/modules/gitea/go.sum +++ b/modules/gitea/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts index 6e06d472..414cf46b 100644 --- a/modules/gitea/guard.ts +++ b/modules/gitea/guard.ts @@ -10,15 +10,16 @@ // Everything else — administration, protection, settings, collaborators, teams, hooks, keys, tokens, // statuses, a merge, a repository deleted or moved — has its own tool or is the operator's, at the // forge as themselves; -// - `gitea_branch_protection_set` only makes protection stronger; -// - `gitea_merge_pull_request` merges only a head whose every required status succeeded, and only that -// head; +// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps +// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5); +// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed +// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only +// that head; // - `gitea_delete_repo` refuses a repository any protection rule guards. // // 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 { @@ -163,7 +164,7 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap `that is not the default branch and that no protection rule names. The forge's administration, a ` + `branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` + `are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` + - `gitea_branch_protection_set, which only strengthens). Nothing was sent`, + `gitea_branch_protection_set, which never lets a person push). Nothing was sent`, }; } @@ -287,7 +288,7 @@ export function judgeContentsWrite(target: string, defaultBranch: string, rules: * Refuses a protection change that weakens what guards a branch (novox/hq ADR 0266): the branch's own rule, or — * when it has none — every glob rule that covers it, since a rule named for the branch takes the glob's place. * A rule made new takes the forge's defaults for what it does not say: pushes refused, an administrator's - * override allowed. + * override allowed. A required status taken away is no weakening: no rule of the mesh requires one (ADR 0534 §5). */ export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted, covering: BranchProtection[] = [], branch = ""): string | null { @@ -305,37 +306,34 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro } return null; // A new rule protects what nothing protected. } - const had = before.enable_status_check ? [...new Set((before.status_check_contexts ?? []).filter(Boolean))] : []; - const wanted = new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean)); - const dropped = had.filter((c) => !wanted.has(c)); const why: string[] = []; - if (dropped.length) why.push(`it drops the required status ${dropped.join(", ")}`); if (want.push === true && before.enable_push === false) why.push("it lets a person push to the branch directly"); if (want.blockAdminOverride === false && before.block_admin_merge_override === true) { - why.push("it lets an administrator merge past a status that has not succeeded"); + why.push("it lets an administrator merge past what the rule asks"); } if (!why.length) return null; return `the protection of ${before.rule_name ?? before.branch_name} is only made stronger through a tool: ` + `${why.join("; ")}. Whoever may call a tool includes agents, and a branch's protection is what keeps a ` + - `change from merging without its checks and the operator (novox/hq ADR 0266) — weakening it is the ` + + `change from landing but through a pull request the operator approved (novox/hq ADR 0266, ADR 0534) — weakening it is the ` + `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`; } diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index b6ed00d0..063ad645 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -13,15 +13,16 @@ // commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment // saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base -// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main -// requires `mesh/repo-check` alone (ADR 0503 §5). +// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since +// ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing +// publishes mesh-controller.checked and these two are written for no base. // // Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and // writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands // over the latest value of every key whenever this module starts. // -// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of, -// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate. +// This module is the one writer of mesh/merge-gate. It hears no build seat's event: since R4g a check's steps are asks +// on the build seats' streams, whose backlog the delivery view shows (novox/hq ADR 0525 §9; issue 1887). // // **A trunk's move puts every open verdict into it out of date** (novox/hq ADR 0323 §4, trunk.ts): each pass reads the // head of every trunk an open pull request merges into, and when one moved, the merge check statuses of every open pull @@ -54,7 +55,7 @@ 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"; +import { GateWriter } from "./gate.js"; import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js"; import { bindInBackground } from "./bind.js"; @@ -258,8 +259,8 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`); // Pending until the verdict comes, so the pull request says a check is running rather than nothing — and // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base - // still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's, and hq's requires - // mesh/repo-check alone; a rule that cannot be read is said pending, as before. + // still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's; + // a rule that cannot be read is said pending, as before. const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined); if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha); return true; @@ -505,23 +506,6 @@ registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusT gates, }), mergeTool(gitea, { gates })] : [])]); -// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when -// the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing. -async function buildStarted(event: { body: unknown }): Promise { - const b = (event.body ?? {}) as BuildStarted; - const ref = String(b.ref ?? "").toLowerCase(); - if (!/^[0-9a-f]{40}$/.test(ref)) return; - for (const [key, sha] of Object.entries(heads)) { - if (sha.toLowerCase() !== ref) continue; - const [owner, rest] = key.split("/"); - const repo = rest?.split("#")[0] ?? ""; - if (owner && repo && clonedAs(b.repository, owner, repo)) { - await gates.started(owner, repo, sha, b); - return; - } - } -} - /** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */ async function actOn(client: GiteaClient, event: ForgeEvent): Promise { const b = event.body as { owner?: string; repo?: string; number?: number }; @@ -625,7 +609,6 @@ if (gitea) { } return setVerdict(client, event); })], - ["node-build-agent.started", () => on("node-build-agent.started", buildStarted)], ], () => console.log("[gitea] watching for new repositories and merged pull requests")); // The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again, // apart from the binds above, so neither waits for the other. diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts index 5ba292f7..a909c743 100644 --- a/modules/gitea/merge.ts +++ b/modules/gitea/merge.ts @@ -5,10 +5,12 @@ // puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked. // Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain. // -// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's, -// whose line names the trunk it judged ("pass on "); where a base does not require it, the controller's -// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is -// asked: the checker sets it out of date on the trunk's move and checks it again. +// **It judges mesh/check whether or not the base requires it** (issue 1301; ADR 0447 §1, §4; ADR 0534 §4): the checker's +// verdict, whose line names the trunk it judged ("pass on "), must be a pass against the trunk's head. +// The forge requires no status to merge (ADR 0534 §1); where a base still requires one of the controller's old statuses, +// until the forge's holder takes it off, that status judges, as before. A mesh/check of another trunk is refused and +// nothing is asked: the checker sets it out of date on the trunk's move and checks it again. Where the base requires no +// status, only a head the operator's Approve names (approved_heads) merges (ADR 0534 §4, ADR 0421 §9). import { announce } from "./announce.js"; import type { ToolDefinition } from "@novox/mesh-sdk/tools"; @@ -36,9 +38,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition now: given.now ?? (() => new Date()) }; 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 " + - "and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " + - "judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " + + description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " + + "its base refuses this account's push, its mesh/check passed judged against the trunk's head as it is now, and, " + + "where the base requires no status (novox/hq ADR 0534), the operator's Approve names it in approved_heads; " + + "optionally deleting the source branch afterwards. Refused " + "otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " + "reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " + "the trunk's head is asked once, with how long the last check of the repository took: ask again at the same head " + @@ -100,11 +103,11 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition `approved (novox/hq ADR 0421). Nothing was merged`); } const rule = await gitea.appliedProtection(owner, repo, pull.base); - // Only into a branch whose protection requires the checks: a pull request whose base was moved to a - // branch nothing guards would merge past every check (novox/hq ADR 0266). - if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) { - throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` + - `the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`); + // Only into a branch whose protection refuses this account's push: a pull request whose base was moved to a + // branch nothing guards would land where anyone pushes (novox/hq ADR 0266, ADR 0534 §4). + if (!rule || rule.enable_push !== false) { + throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule refusing ` + + `a push guards: a tool merges only into such a branch (novox/hq ADR 0266, ADR 0534). Nothing was merged`); } // **The verdict must be judged against the trunk's head** (novox/hq ADR 0323 §5, issue 341): a check judges the // head merged into a named trunk commit, and a merge of another trunk is not the one that check judged. Compared @@ -114,7 +117,16 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition const trunkHead = await gitea.branchHead(owner, repo, pull.base); const checks = await gitea.headChecks(owner, repo, pull.head_sha); const required = requiredOf(rule) ?? []; - if (required.includes(CHECK_STATUS)) { + // **The forge requires no status; the operator's Approve and mesh/check do** (ADR 0534 §4): where the base requires + // no status, only a head the Approve names merges, and mesh/check judges it as where it was required. + if (required.length === 0 && !approved) { + throw new Refused(`${owner}/${repo}#${number} is not merged: ${pull.base} requires no status, so a merge into it ` + + `lands only a head the operator's Approve names: give approved_heads with ${owner}/${repo}@ ` + + `(novox/hq ADR 0534, ADR 0421 §9). Nothing was merged`); + } + const kept = maintained(required); + const byCheck = required.includes(CHECK_STATUS) || kept.length === 0; + if (byCheck) { // **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]; @@ -124,6 +136,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition `${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` + `${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`); } + if (check.state !== "success") { + throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ${check.state} ` + + `(${check.description ?? ""}). A pull request merges once its check passes (novox/hq ADR 0534). Nothing was merged`); + } if (!at.current) { throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` + `${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` + @@ -131,9 +147,8 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition `ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`); } } - // Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a + // Where the base still requires the controller's status (mesh/merge-gate, only on a // revert), the first it requires judges, as before (ADR 0323 §5). - const kept = maintained(required); const judge = kept[0] ?? CHECK_CONTEXT; const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false }; if (!at.current) { @@ -161,7 +176,8 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition // Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An // announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather // than wait for ever on a check nobody asked. - // The statuses the base requires of the controller's (issue 1301): hq's mesh/repo-check alone. + // The statuses the base requires of the controller's (issue 1301): the one that judges, and the repository's own + // check beside it where a revert has the base require both. const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT])); // Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new // check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older. @@ -197,9 +213,9 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition // of them is no verdict to merge on, never read as none. // Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the // Approve's heads above; a record the controller's verdicts left before the switch judges nothing there. - const heard = required.includes(CHECK_STATUS) ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)]; + const heard = byCheck ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)]; const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : []; - if (!siblings.length && !required.includes(CHECK_STATUS) && + if (!siblings.length && !byCheck && namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) { throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` + `here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`); diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 9d1856a5..1ac1aeb8 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -86,8 +86,7 @@ "forge.other" ], "consumes": [ - "mesh-controller.checked", - "node-build-agent.started" + "mesh-controller.checked" ], "listens": [ { @@ -172,15 +171,21 @@ }, "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.", + "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, requiring no status. False (the default): the module changes no existing repository's branch protection, whoever set it at the forge, but takes away a required status it finds on a rule covering main, once (ADR 0534 §1); 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.", + "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 ), 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. With keep-main-rules false a listed repository that exists likewise has only its required statuses taken off, once.", "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": { @@ -310,7 +315,8 @@ "merge": "json", "takes-config": [ "url", - "keep-main-rules" + "keep-main-rules", + "main-mergers" ] }, { @@ -373,10 +379,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" ] } ], diff --git a/modules/gitea/protection.ts b/modules/gitea/protection.ts index 5336a827..f749c470 100644 --- a/modules/gitea/protection.ts +++ b/modules/gitea/protection.ts @@ -1,7 +1,9 @@ -// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`, -// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) — -// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts). +// A branch's protection (novox/hq ADR 0237): no direct push, and on main only the tools' account merges (ADR 0398 §2). +// The forge requires no status to merge (ADR 0534 §1): the checks still run and report, and mesh-delivery and the +// 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). */ @@ -24,26 +26,50 @@ export interface BranchProtection { /** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's * default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */ export interface ProtectionWanted { - /** The statuses a pull request must have succeeded before it merges, e.g. mesh/check. Empty: none. */ - statusChecks: string[]; /** Whether a person may push to the branch directly. */ push?: boolean; - /** Whether an administrator is stopped from merging past a status that has not succeeded. */ + /** Whether an administrator is stopped from merging past what the rule asks. */ blockAdminOverride?: boolean; /** Who may merge into the branch; for main only ever the tools' account (ADR 0398 §2). Not given: left as it is. * Empty or null asks for no whitelist, which the guard refuses for main; the body for main ignores it. */ mergeWhitelist?: string[] | null; } -/** What main's rule always says: no push, and only the tools' account merges (novox/hq ADR 0398 §2, issue 948). */ -export function mainRuleBody(): Record { - return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT] }; +/** What every rule the mesh writes says of statuses: none is required to merge (novox/hq ADR 0534 §1). */ +export function noStatusBody(): Record { + return { enable_status_check: false, status_check_contexts: [] }; +} + +/** 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 { + return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: mergers, ...noStatusBody() }; } /** The forge's body for a wanted protection. */ export function protectionBody(want: ProtectionWanted, creating: boolean, branch = ""): Record { - const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))]; - const body: Record = { enable_status_check: checks.length > 0, status_check_contexts: checks }; + const body: Record = noStatusBody(); if (want.push !== undefined) body.enable_push = want.push; else if (creating) body.enable_push = false; if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride; diff --git a/modules/gitea/provisioner/teams.ts b/modules/gitea/provisioner/teams.ts index 3183af79..61100be8 100644 --- a/modules/gitea/provisioner/teams.ts +++ b/modules/gitea/provisioner/teams.ts @@ -2,7 +2,7 @@ // half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in. // // **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes — -// the build agent, whose builds publish the SDK and every TypeScript module's packages — joins `packages`: +// the build modules, whose builds publish the SDK and every TypeScript module's packages — joins `packages`: // code read on the org's repositories and write on its packages. A consumer that contributes // `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it // clones branches that are not yet reviewed and runs their code beside its credential, and code that is diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 98281f29..06052e70 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -109,7 +109,7 @@ const server = createServer(async (req, res) => { const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch)); return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule, ...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check, - status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) }); + status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: !!rule.enable_push } : {}) }); } if (method === "GET" && path === `${repo}/pulls/7/files`) { return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []); @@ -237,18 +237,24 @@ test("gitea_api still writes what an author writes", async () => { assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments", "POST /repos/novox/mesh-catalog/pulls/7/reviews"]); }); -test("gitea_branch_protection_set only strengthens", async () => { +// **The forge requires no status to merge** (novox/hq ADR 0534 §5): the tool writes no required status and takes no +// status argument; taking a required status away is no weakening it refuses, and a push or an administrator's merge +// past the rule still is. +test("gitea_branch_protection_set writes no required status, and refuses a push or an administrator's override", async () => { state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge const set = (args: Record) => tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args }); - await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/); - await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /drops the required status mesh\/repo-check/); - await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", push: true }), /push to the branch directly/); - await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", block_admin_override: false }), - /merge past a status/); + assert.equal((tool("gitea_branch_protection_set").input as Record).status_checks, undefined, + "the tool takes no status argument"); + await assert.rejects(set({ push: true }), /push to the branch directly/); + await assert.rejects(set({ block_admin_override: false }), /merge past/); assert.deepEqual(writes(), []); - await set({ status_checks: "mesh/merge-gate,mesh/repo-check,mesh/delivery" }); + await set({ status_checks: "mesh/check" }); // an argument it no longer takes is not read assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/branch_protections/main"]); + const sent = state.sent[0].body; + assert.equal(sent.enable_status_check, false); + assert.deepEqual(sent.status_check_contexts, []); + assert.equal(sent.enable_push, false); }); test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => { @@ -474,26 +480,62 @@ test("gitea_api sends the path it judged, and refuses moving a pull request's ba assert.equal(writes().length, 2); }); -test("gitea_merge_pull_request merges only into a branch whose protection requires the checks", async () => { +test("gitea_merge_pull_request merges only into a branch whose protection refuses its push", async () => { + const approved_heads = [`novox/mesh-catalog@${REVIEWED}`]; + state.statuses = [{ context: "mesh/check", state: "success", description: `pass ${REVIEWED.slice(0, 12)} on ${TRUNK.slice(0, 12)}` }]; state.rules = []; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }), - /no protection rule requiring the checks guards/); - state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [] }]; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }), - /no protection rule requiring the checks guards/); + await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, + approved_heads }), /no protection rule refusing/); + state.rules = [{ rule_name: "main", enable_push: true, enable_status_check: false, status_check_contexts: [] }]; + await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, + approved_heads }), /no protection rule refusing/); assert.deepEqual(writes(), []); }); +// **The forge requires no status to merge; the merge tool still judges mesh/check, and asks the operator's Approve** +// (novox/hq ADR 0534 §4): on a main whose rule takes no push and requires no status, a head merges only with +// approved_heads naming it and a mesh/check that passed against the trunk's head at the merge. +test("gitea_merge_pull_request judges mesh/check on a main requiring no status, and merges only an approved head", async () => { + const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); + keepSiblings(siblingsFile(stateDir), {}); + const approved_heads = [`novox/mesh-catalog@${REVIEWED}`]; + const merge = (args: Record = { approved_heads }) => + tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args }); + state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [], + block_admin_merge_override: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }]; + const line = (trunk: string, s = "success", words = "pass") => + [{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }]; + const before = state.emitted.length; + + state.statuses = line(TRUNK); + await assert.rejects(merge({}), /Approve/); + state.statuses = []; + await assert.rejects(merge(), /mesh\/check is not set yet/); + state.statuses = line(TRUNK, "pending", "checking"); + await assert.rejects(merge(), /mesh\/check is pending/); + state.statuses = line(TRUNK, "failure", "fail"); + await assert.rejects(merge(), /mesh\/check is failure/); + state.statuses = line(MOVED); + await assert.rejects(merge(), new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`)); + state.statuses = judgedAt(TRUNK); // the old two, current: not mesh/check + await assert.rejects(merge(), /mesh\/check/); + assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's"); + assert.equal(state.emitted.length, before, "no check is asked"); + assert.equal(state.merged, false); + + state.statuses = line(TRUNK); + assert.equal(((await merge()) as Record).merged, true); +}); + test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => { state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true }); const set = (args: Record) => tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args }); - await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate/); - await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /merge past a status/); + await assert.rejects(set({}), /merge past/); assert.deepEqual(writes(), []); - await set({ status_checks: "mesh/merge-gate", block_admin_override: true }); + await set({ block_admin_override: true }); // the glob's required status is no longer asked (ADR 0534 §5) assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]); }); @@ -511,26 +553,25 @@ test("the judges, without a forge", () => { assert.ok("refused" in judgeApiCall("POST", "/repos/a/b/branch_protections", {})); assert.ok("contents" in judgeApiCall("POST", "/repos/a/b/contents", { new_branch: "x" })); assert.ok(ruleCovers("release/**", "release/a/b") && ruleCovers("feat/*", "feat/a") && !ruleCovers("feat/*", "feat/a/b")); - assert.equal(judgeProtectionChange(null, { statusChecks: [] }), null); - assert.equal(judgeProtectionChange(protectedMain(), { statusChecks: ["mesh/repo-check", "mesh/merge-gate"] }), null); + assert.equal(judgeProtectionChange(null, {}), null); + assert.equal(judgeProtectionChange(protectedMain(), {}), null, "taking its required statuses off is no weakening (ADR 0534 §5)"); }); test("main's protection is never weakened in its push or its merge whitelist (issue 948, ADR 0398 §2)", () => { const strong = () => ({ ...protectedMain(), enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }); - const checks = ["mesh/merge-gate", "mesh/repo-check"]; - assert.equal(judgeProtectionChange(strong(), { statusChecks: checks }, [], "main"), null); - assert.equal(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools"] }, [], "main"), null); - assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/); - assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/); - assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/); - assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/); - assert.match(judgeProtectionChange(strong(), { statusChecks: checks, push: true }, [], "main") ?? "", /push/); + assert.equal(judgeProtectionChange(strong(), {}, [], "main"), null); + assert.equal(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools"] }, [], "main"), null); + assert.match(judgeProtectionChange(strong(), { mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/); + assert.match(judgeProtectionChange(strong(), { mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/); + assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/); + assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/); + assert.match(judgeProtectionChange(strong(), { push: true }, [], "main") ?? "", /push/); // Even a rule that lets a person push already, or has no rule yet, is not given a push through a tool. - assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { statusChecks: checks, push: true }, [], "main") ?? "", /push/); - assert.match(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "main") ?? "", /push/); - assert.match(judgeProtectionChange(null, { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/); + assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { push: true }, [], "main") ?? "", /push/); + assert.match(judgeProtectionChange(null, { push: true }, [], "main") ?? "", /push/); + assert.match(judgeProtectionChange(null, { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/); // Another branch is judged as before. - assert.equal(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "feat/x"), null); + assert.equal(judgeProtectionChange(null, { push: true }, [], "feat/x"), null); }); test("gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused", async () => { diff --git a/modules/gitea/test/protection-read.test.ts b/modules/gitea/test/protection-read.test.ts index d2928ab9..b774da45 100644 --- a/modules/gitea/test/protection-read.test.ts +++ b/modules/gitea/test/protection-read.test.ts @@ -52,7 +52,7 @@ test("gitea_branch_protection_get answers the rule for a branch, null when it ha test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => { await assert.rejects( - tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }), + tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main" }), /403/, ); assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n")); diff --git a/modules/gitea/test/protection.test.ts b/modules/gitea/test/protection.test.ts index d75422d7..5f7779e0 100644 --- a/modules/gitea/test/protection.test.ts +++ b/modules/gitea/test/protection.test.ts @@ -4,32 +4,33 @@ import { test } from "node:test"; // A branch's protection (novox/hq ADR 0237): what the operator's agent sets so a pull request waits for the // mesh's merge check before it merges. -test("the statuses asked for replace the rule's, and an empty list requires none", async () => { +test("a rule's body requires no status, whatever the rule required (ADR 0534 §5)", async () => { const { protectionBody } = await import("../dist/protection.js"); - assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false), - { enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }, - "an edited rule keeps its pushes as they are, and each status once"); - assert.deepEqual(protectionBody({ statusChecks: [] }, false), { enable_status_check: false, status_check_contexts: [] }); + assert.deepEqual(protectionBody({}, false), { enable_status_check: false, status_check_contexts: [] }, + "an edited rule keeps its pushes as they are, and requires no status"); }); test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => { const { protectionBody } = await import("../dist/protection.js"); - assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false); - assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true); - assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined); - assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true); + assert.equal(protectionBody({}, true).enable_push, false); + assert.equal(protectionBody({ push: true }, true).enable_push, true); + assert.equal(protectionBody({}, true).block_admin_merge_override, undefined); + assert.equal(protectionBody({ blockAdminOverride: true }, false).block_admin_merge_override, true); }); -// Issue 948 (novox/hq ADR 0398 §2): main takes no push, and only the tools' account merges. +// Issue 948 (novox/hq ADR 0398 §2): main takes no push, and only the tools' account merges; it requires no status +// (ADR 0534 §1). test("main's body always refuses pushes and lets only the tools' account merge, whatever is asked", async () => { const { protectionBody } = await import("../dist/protection.js"); const { TOOLS_ACCOUNT } = await import("../dist/tools-account.js"); for (const creating of [true, false]) { - for (const want of [{ statusChecks: ["mesh/merge-gate"] }, { statusChecks: [], push: true }, { statusChecks: [], push: false }]) { + for (const want of [{}, { push: true }, { push: false }]) { const body = protectionBody(want, creating, "main"); assert.equal(body.enable_push, false, "main takes no push, an edit and a push=true included"); assert.equal(body.enable_merge_whitelist, true); assert.deepEqual(body.merge_whitelist_usernames, [TOOLS_ACCOUNT]); + assert.equal(body.enable_status_check, false); + assert.deepEqual(body.status_check_contexts, []); } } assert.deepEqual(TOOLS_ACCOUNT, "mesh-tools"); @@ -37,13 +38,28 @@ test("main's body always refuses pushes and lets only the tools' account merge, test("another branch's body carries no merge whitelist", async () => { const { protectionBody } = await import("../dist/protection.js"); - const body = protectionBody({ statusChecks: ["mesh/merge-gate"] }, false, "release/1"); + const body = protectionBody({}, false, "release/1"); assert.equal(body.enable_merge_whitelist, undefined); assert.equal(body.merge_whitelist_usernames, undefined); - assert.equal(protectionBody({ statusChecks: [] }, false).enable_merge_whitelist, undefined); }); -test("the rule main is put right in the forge's own words: the body a setup sends carries no status checks of its own", async () => { +test("the rule main is put right in the forge's own words: no push, the tools' account merges, no status required", async () => { const { mainRuleBody } = await import("../dist/protection.js"); - assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }); + 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"]); }); diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index 6c9b3552..6d27aefd 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -238,7 +238,7 @@ test("with the tools' token, the forge writes a status and refuses a protection const client = fresh(env); await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any); await assert.rejects( - client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }), + client.setBranchProtection("novox", "hq", "main", {}), /403/, ); }); @@ -353,7 +353,7 @@ test("a team another process made between the look and the create is found, not assert.ok(team?.members.has(TOOLS_ACCOUNT)); }); -test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges (issue 948)", async () => { +test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges, no status (issue 948, ADR 0534 §1)", async () => { const env = await environment(true); const from = forge.calls.length; await fresh(env).listRepos(); @@ -361,7 +361,8 @@ test("setting the tools' account up puts main's rule right in every repository: assert.equal(hq.enable_push, false); assert.equal(hq.enable_merge_whitelist, true); assert.deepEqual(hq.merge_whitelist_usernames, [TOOLS_ACCOUNT]); - assert.deepEqual(hq.status_check_contexts, ["mesh/merge-gate"], "a rule found keeps the statuses it requires"); + assert.deepEqual(hq.status_check_contexts, [], "a rule found has its required statuses taken off (ADR 0534 §1)"); + assert.equal(hq.enable_status_check, false); const catalog = forge.mainRules.get("mesh-catalog"); assert.ok(catalog, "a repository with no rule for main is given one"); assert.equal(catalog.rule_name, "main"); @@ -371,13 +372,13 @@ test("setting the tools' account up puts main's rule right in every repository: "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 () => { +test("a glob rule covering main gives its approvals and override, not its statuses, when main is given a rule of its own name (issue 948, ADR 0534 §1)", async () => { const env = await environment(true); 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.deepEqual(rule.status_check_contexts, []); + assert.equal(rule.enable_status_check, false); assert.equal(rule.block_admin_merge_override, true); assert.equal(rule.required_approvals, 1); assert.equal(rule.enable_push, false); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 7100fdbc..e22a4965 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -429,14 +429,13 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini }, { name: "gitea_branch_protection_set", - description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's checker sets `mesh/check` (ADR 0447), and the repositories program requires it on every main. Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", + description: "Protect a branch. Never weaker (novox/hq ADR 0266): a change that lets a person push directly or lets an administrator merge past the rule is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. The forge requires no status to merge (ADR 0534): the rule written requires none, and one the rule required is taken off; the checks are judged by mesh-delivery and the merge tool. Edits the branch's rule (everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", input: { owner: { type: "string", description: "the repository owner" }, repo: { type: "string", description: "the repository name" }, branch: { type: "string", description: "the branch to protect, e.g. main" }, - status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/check; empty requires none" }, push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" }, - block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" }, + block_admin_override: { type: "boolean", description: "stop an administrator merging past what the rule asks (left as it is when not given)" }, }, run: async (args) => { const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim(); @@ -445,7 +444,6 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini const before = await gitea.branchProtection(owner, repo, branch); const want = { - statusChecks: String(args.status_checks ?? "").split(","), push: args.push === undefined ? undefined : Boolean(args.push), blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override), }; diff --git a/modules/gitea/trunk.ts b/modules/gitea/trunk.ts index 11100ca1..55866f30 100644 --- a/modules/gitea/trunk.ts +++ b/modules/gitea/trunk.ts @@ -14,8 +14,8 @@ // // Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged // ("pass on "), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict -// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (hq's -// mesh/repo-check, pulls.ts maintained). +// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (a revert, +// pulls.ts maintained). // // Pure functions and two small files of the module's own state, so this is tested without a forge. @@ -234,14 +234,11 @@ export async function announcementOf(client: GiteaClient, owner: string, name: s const files = await client.listPullFiles(owner, name, pull.number); const head = String(pull.head_sha); // What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as amended): which - // changed directories hold a module at the head — the merge's rule (issue 278) — and whether the head holds the - // repository's own merge-check.sh. Not said when it could not be read; the controller then reads the change as + // changed directories hold a module at the head — the merge's rule (issue 278). Not said when it could not be read; the controller then reads the change as // touching everything built from the repository. let moduleDirs: string[] | null = null; - let mergeCheck: boolean | null = null; try { if (!files.truncated) moduleDirs = await client.moduleDirsAt(owner, name, head, files.paths); - mergeCheck = await client.holdsFile(owner, name, head, "merge-check.sh"); } catch (err) { console.error(`[gitea] ${owner}/${name}#${pull.number}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`); } @@ -264,6 +261,5 @@ export async function announcementOf(client: GiteaClient, owner: string, name: s paths_truncated: files.truncated, removed: files.removed, ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), - ...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}), }; } diff --git a/modules/lab/tools/runs.ts b/modules/lab/tools/runs.ts index c65bfb41..a253c069 100644 --- a/modules/lab/tools/runs.ts +++ b/modules/lab/tools/runs.ts @@ -166,7 +166,7 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch, publ * The forge credential a run's clones may present, as a git credential-store line — * scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none. * - * **The mesh's credential for the forge, the way the build agent holds it** (novox/hq issue 624): the lab + * **The mesh's credential for the forge, the way the build modules hold it** (novox/hq issue 624): the lab * requires the package registry, the forge's provisioner binds it an account (`as`) and seals that * account's password, and the forge that answers npm is the forge that hosts the repositories. Private * repositories (mesh-tools-go) are cloned with it; public ones clone as before. Written to a file git reads diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks.go b/modules/mesh-build-go/cmd/mesh-build-go/asks.go new file mode 100644 index 00000000..dd516941 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **The module pulls its seat's asks** (novox/hq ADR 0525 §2-§6, ADR 0536 §4; issue 1887 A3go). It holds the seat +// node-build-go on its node and pulls the seat's asks through its runtime (buildstep.SeatAsks): delivery first, then +// check, then recheck, at most the node's build capacity at once (its setting `capacity`, read at every pull). Each ask +// runs with the same step bodies its tools run, behind the same guard, and ends on the key . of its steps +// state. The tools stay as the hand path (ADR 0525 §8). + +// Seat is the node seat this module holds. +const Seat = "node-build-go" + +// KeptState is the checker's kept verdicts, which a check ask takes for the packages whose digest has one (issue +// 1887's design limit 1). +const KeptState = delivery.CheckerModule + ".kept" + +// askSteps are the module's step bodies for an ask, each holding its workspace while it runs, as a tool's does. +func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { + byName := map[string]buildstep.StepFunc{} + for _, s := range steps { + run := s.Run + byName[s.Name] = func(ctx context.Context, args map[string]any) (any, error) { + defer m.holdArg(args)() + return run(ctx, args) + } + } + return buildstep.AskSteps{ + Fetch: byName["build_go_fetch"], Build: byName["build_go_build"], Digest: byName["build_go_digest"], Test: byName["build_go_test"], + Toolchain: func(ctx context.Context) (delivery.ToolchainUsed, error) { + installed, err := m.Probe(ctx) + if err != nil { + return delivery.ToolchainUsed{}, err + } + return delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil + }, + Kept: m.KeptLookup, + Release: func(ws string) error { return m.spaces().Release(ws) }, + Logf: m.Say, + } +} + +// keptValue reads one key of the checker's kept state; nil when it is absent. Main sets it to the state's read; a test +// its own. +func (m *Module) keptValue(key string) (json.RawMessage, error) { + if m.ReadKept == nil { + return nil, nil + } + return m.ReadKept(key) +} + +// KeptLookup is whether the checker kept a verdict for the level and digest, and how many tests it counted. +func (m *Module) KeptLookup(ctx context.Context, level, digest string) (int, bool, error) { + key, err := delivery.KeptKey(level, digest) + if err != nil { + return 0, false, err + } + raw, err := m.keptValue(key) + if err != nil || raw == nil { + return 0, false, err + } + v, err := delivery.DecodeKept(raw) + if err != nil { + return 0, false, fmt.Errorf("%s holds what is not a kept verdict: %w", key, err) + } + return v.Tests, true, nil +} + +// Puller is the module's pull of its seat's asks, on source (the runtime's seat pull when nil). +func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep.Puller { + if source == nil { + source = buildstep.SeatAsks{Language: delivery.AskGo} + } + capacity := m.Capacity + if capacity == nil { + capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + } + ask := m.askSteps(steps) + var load func(string) ([]byte, error) + if m.ReadSteps.Get != nil { + load = func(key string) ([]byte, error) { + raw, err := m.ReadSteps.Get(key) + return raw, err + } + } + return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, Load: load, + Capacity: capacity, Logf: m.Say, + Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + if err := m.Check(ctx); err != nil { + return nil, err + } + if err := m.room(); err != nil { + return nil, err + } + return buildstep.RunAsk(ctx, ask, a) + }} +} + +// SeatTools are the seat's verbs on this node (ADR 0525 §10): what it runs, and its pause and resume. +func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { + return buildstep.SeatTools(Seat, "Go", p) +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/asks_integration_test.go new file mode 100644 index 00000000..e8519808 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks_integration_test.go @@ -0,0 +1,112 @@ +//go:build integration + +package main + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +// checkCommit is the commit the check ask fetches, apart from the delivery ask's, so the fetch order names which ran first. +const checkCommit = "89abcdef0123456789abcdef0123456789abcdef" + +// 1887/AC-order, 1887/AC-pull against the real bus: a check ask published before a delivery ask runs after it, and at +// capacity 1 the second waits in the stream until the first ends. +// +// **A bus of its own.** The integration level runs every directory's tests at once on one bus, and this test names the +// build seat's stream and its fixed workers (SEAT_NODE_BUILD_GO and its workers) and deletes the stream at its end, so +// anything else on that bus, a puller or a leftover consumer, could hold or take its delivery ask and let the check +// ask run first. It starts from testbus (mesh-sdk, novox/hq ADR 0495 clause 5): a bus for this test alone, gone after it. +// +// **The order is read from the record.** At capacity 1 the puller starts the check the instant the delivery ask's step +// ends, well before any poll, so "the check key holds a state" is true on correct behaviour (issue 1887). The test +// waits for both asks to end and compares the step records' own Started and Ended times, and the fetch order by commit. +func TestTheModuleTakesADeliveryAskBeforeAnEarlierCheck(t *testing.T) { + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, stop := context.WithCancel(context.Background()) + defer stop() + if err := buildstep.DeclareAskWorkers(ctx, js, delivery.AskGo, 30*time.Second); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = js.DeleteStream(context.Background(), "SEAT_NODE_BUILD_GO") }) + check := deliveryAsk() + check.Kind = delivery.AskCheck + check.Build.Fetch["commit"] = checkCommit + first := deliveryAsk() + for _, a := range []delivery.StepAsk{check, first} { + raw, _ := delivery.EncodeStepAsk(a) + subject, _ := delivery.AskSubject(a.Language, a.Kind) + if _, err := js.Publish(ctx, subject, raw); err != nil { + t.Fatal(err) + } + } + states := newStates() + m := moduleWith(states, "go1.26.0") + m.Capacity = func() int { return 1 } + var mu sync.Mutex + var order []string + steps := fakeSteps(new([]string)) + for i := range steps { + if steps[i].Name == "build_go_fetch" { + run := steps[i].Run + steps[i].Run = func(ctx context.Context, args map[string]any) (any, error) { + mu.Lock() + order = append(order, args["commit"].(string)) + mu.Unlock() + return run(ctx, args) + } + } + } + p := m.Puller(steps, &buildstep.JetStreamAsks{JS: js, Language: delivery.AskGo}) + p.PullWait = 200 * time.Millisecond + go p.Pull(ctx) + var ran map[string][]delivery.Step + for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(10 * time.Millisecond) { + ran = map[string][]delivery.Step{} + ended := 0 + states.mu.Lock() + for _, a := range []delivery.StepAsk{first, check} { + key, _ := delivery.AskStepKey("ace", a.ID) + v := append([]delivery.Step(nil), states.steps[key]...) + ran[a.ID] = v + if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + ended++ + } + } + states.mu.Unlock() + if ended == 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("the asks never both ended: %v", ran) + } + } + // The check's step may start the moment the delivery's ends, so the order is read from the records' own times. + deliveryEnded := ran[first.ID][len(ran[first.ID])-1].Ended + checkStarted := ran[check.ID][0].Started + if checkStarted.Before(deliveryEnded) { + t.Fatalf("the check ask started at %v, before the delivery ask ended at %v at capacity 1", checkStarted, deliveryEnded) + } + mu.Lock() + defer mu.Unlock() + if len(order) != 2 || order[0] != askCommit || order[1] != checkCommit { + t.Fatalf("fetched %v, want the delivery ask's commit %s first, then the check's %s", order, askCommit, checkCommit) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go new file mode 100644 index 00000000..5f94f37a --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const askCommit = "0123456789abcdef0123456789abcdef01234567" + +// fakeSteps are step tools that answer at once: a fetch with a workspace, a build with a digest and a reference. +func fakeSteps(calls *[]string) []StepTool { + answer := func(name string, out any) StepTool { + return StepTool{Name: name, Run: func(ctx context.Context, args map[string]any) (any, error) { + *calls = append(*calls, name) + return out, nil + }} + } + return []StepTool{ + answer("build_go_fetch", map[string]any{"workspace": "ws1"}), + answer("build_go_build", map[string]any{"digest": "sha256:aa", "reference": "artifact-store://mesh-build-go/tools@sha256:aa"}), + answer("build_go_digest", map[string]any{"level": "unit", "packages": map[string]string{}}), + answer("build_go_test", map[string]any{"passed": true}), + } +} + +func deliveryAsk() delivery.StepAsk { + return delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskDelivery, Language: delivery.AskGo, Asker: "mesh-delivery-builder", + At: time.Now(), Build: &delivery.BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", Publish: true, + Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit}, + Artifacts: []delivery.AskArtifact{{Artifact: "program"}}}} +} + +// 1887/A3go: an ask pulled runs build_go_build after build_go_fetch, and the step key . names the +// reference. +func TestAPulledAskRunsTheBuildAndItsKeyNamesTheReference(t *testing.T) { + states := newStates() + m := moduleWith(states, "go1.26.0") + m.Capacity = func() int { return 1 } + var calls []string + src := buildstep.NewFakeAsks() + a := deliveryAsk() + if err := src.PublishAsk(a); err != nil { + t.Fatal(err) + } + p := m.Puller(fakeSteps(&calls), src) + p.PullWait = 10 * time.Millisecond + ctx, stop := context.WithCancel(context.Background()) + defer stop() + go p.Pull(ctx) + key, _ := delivery.AskStepKey("ace", a.ID) + var step delivery.Step + for deadline := time.Now().Add(3 * time.Second); ; time.Sleep(5 * time.Millisecond) { + states.mu.Lock() + v := states.steps[key] + states.mu.Unlock() + if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + step = v[len(v)-1] + break + } + if time.Now().After(deadline) { + t.Fatalf("the ask's step %s never ended: %v", key, v) + } + } + if step.State != delivery.StepPassed || !strings.Contains(string(step.Result), "artifact-store://mesh-build-go/tools@sha256:aa") { + t.Fatalf("the step = %+v", step) + } + if strings.Join(calls, ",") != "build_go_fetch,build_go_build" { + t.Errorf("calls = %v", calls) + } + if acked, _, _ := src.Fates(); len(acked) != 1 || acked[0] != a.ID { + t.Errorf("acked %v", acked) + } +} + +// 1887/AC-zero: at capacity 0 the node's module pulls nothing, and its tools still answer a direct call. +func TestAtCapacityZeroNothingIsPulledAndTheToolsAnswer(t *testing.T) { + states := newStates() + m := moduleWith(states, "go1.26.0") + m.Capacity = func() int { return 0 } + var calls []string + steps := fakeSteps(&calls) + src := buildstep.NewFakeAsks() + _ = src.PublishAsk(deliveryAsk()) + p := m.Puller(steps, src) + p.PullWait = 10 * time.Millisecond + ctx, stop := context.WithCancel(context.Background()) + defer stop() + go p.Pull(ctx) + time.Sleep(50 * time.Millisecond) + if src.Waiting(delivery.AskDelivery) != 1 || len(calls) != 0 { + t.Fatalf("a node at capacity 0 pulled: waiting %d, calls %v", src.Waiting(delivery.AskDelivery), calls) + } + for _, tool := range m.Tools(steps) { + if tool.Name == "build_go_fetch" { + out, err := tool.Run(map[string]any{"repository": "novox/x", "commit": askCommit}) + if err != nil || !strings.Contains(string(mustJSON(out)), `"workspace":"ws1"`) || len(calls) != 1 { + t.Fatalf("the direct call answered %v, %v", out, err) + } + return + } + } + t.Fatal("no fetch tool") +} + +func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } + +// 1887/A3go, A7: the manifest claims node-build-go serving its three verbs, reads the checker's kept verdicts and takes +// the node's build capacity as a setting, with the builder's default; the seat's verbs are served as its tools. +func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) { + m := readManifest(t) + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) { + t.Fatalf("claims = %+v", m.Claims) + } + if !slices.Contains(m.Reads, KeptState) { + t.Errorf("reads = %v lack %s", m.Reads, KeptState) + } + raw, _ := os.ReadFile(moduleDir + "/module.json") + var doc struct { + Config map[string]struct { + Default string `json:"default"` + } `json:"config"` + } + _ = json.Unmarshal(raw, &doc) + if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities { + t.Errorf("the capacity's default = %q, want %q", doc.Config[buildstep.CapacitySetting].Default, buildstep.DefaultCapacities) + } + settings := resource(t, m, "settings") + if settings["path"] != "${dir:state}/settings.json" { + t.Errorf("the settings file is at %v", settings["path"]) + } + served := map[string]bool{} + for _, tool := range m0().SeatTools(m0().Puller(nil, buildstep.NewFakeAsks())) { + served[tool.Name] = true + } + for _, verb := range m.Claims[0].Serves { + if !served[Seat+"."+verb] { + t.Errorf("%s.%s is not served", Seat, verb) + } + } +} + +func m0() *Module { return &Module{Node: "ace", Capacity: func() int { return 0 }} } + +// 1917: a check ask's digest is the digest tool's own body, which answers the packages in the call (no step key): the +// ask's level carries the digest, so the checker keys the kept verdicts by it. +func TestACheckAsksDigestIsTheToolsAnswerInTheCall(t *testing.T) { + m := &Module{Node: "ace"} + for _, s := range m.StepTools() { + if s.Name == "build_go_digest" && s.Long { + t.Fatal("build_go_digest is a long step: its answer would be a step key, not the digest") + } + } + var calls []string + steps := fakeSteps(&calls) + for i := range steps { + if steps[i].Name == "build_go_digest" { + steps[i].Run = func(ctx context.Context, args map[string]any) (any, error) { + return map[string]any{"level": "unit", "packages": map[string]string{"x/a": "d1"}}, nil + } + } + } + ask := m.askSteps(steps) + a := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-checker", + At: time.Now(), Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": askCommit}, Levels: []string{delivery.LevelUnit}}} + out, err := buildstep.RunAsk(context.Background(), ask, a) + if err != nil { + t.Fatal(err) + } + if l := out.(delivery.CheckAnswer).Levels; len(l) != 1 || !strings.Contains(string(l[0].Digest), `"x/a":"d1"`) { + t.Fatalf("levels = %+v, want the digest's packages", l) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go index 3abc2400..89ff15c8 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go @@ -144,8 +144,9 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) // artifact-store: the module pushes a done build's archive itself (ADR 0498 §2). - if len(m.Requires) != 3 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" || m.Requires[2] != "artifact-store" { - t.Errorf("requires = %v, want [forge-account go-toolchain artifact-store]", m.Requires) + // node-tools: the module pulls its asks through the tool runner's seat pull (1911). + if len(m.Requires) != 4 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" || m.Requires[2] != "artifact-store" || m.Requires[3] != "node-tools" { + t.Errorf("requires = %v, want [forge-account go-toolchain artifact-store node-tools]", m.Requires) } keeps := false for _, s := range m.State { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/levels.go b/modules/mesh-build-go/cmd/mesh-build-go/levels.go index 455ca95f..6af18e3f 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/levels.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/levels.go @@ -70,6 +70,8 @@ type TestResult struct { Elapsed float64 `json:"elapsed"` // Setup is the level's setup time in seconds, when it has a setup. Setup float64 `json:"setup,omitempty"` + // Lint is the unit level's gofmt and go vet time in seconds, before its tests (ADR 0529 §1). + Lint float64 `json:"lint,omitempty"` // Bound and SetupBound are the bounds the level was judged by, in seconds. Bound float64 `json:"bound"` SetupBound float64 `json:"setup_bound,omitempty"` diff --git a/modules/mesh-build-go/cmd/mesh-build-go/lint.go b/modules/mesh-build-go/cmd/mesh-build-go/lint.go new file mode 100644 index 00000000..e493f12b --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/lint.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "go/format" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// **gofmt and go vet are the unit level's** (novox/hq ADR 0529 §1; issue 1887 D7-go): what every repository's script ran +// beside its tests, run for every repository by the build module that runs its unit level. In each go.mod directory the +// level runs, every Go file outside testdata and vendor must be as gofmt writes it, and `go vet ./...` must pass with no +// tag and with every build tag the directory's files name together (integration, isolation, ...), so code behind a tag +// nothing else compiles is vetted too. A failure is the level's, naming the files or vet's lines. + +// buildTag is one name in a //go:build line. +var buildTag = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_.]*`) + +// notATag are the names of a //go:build line that are no tag a run sets: the platforms, cgo, ignore, go versions. +var notATag = map[string]bool{"ignore": true, "cgo": true, "linux": true, "darwin": true, "windows": true, "freebsd": true, + "openbsd": true, "netbsd": true, "unix": true, "plan9": true, "js": true, "wasip1": true, "android": true, "ios": true, + "amd64": true, "arm64": true, "386": true, "arm": true, "riscv64": true, "wasm": true, "ppc64le": true, "s390x": true} + +// lintable walks a go.mod directory's own Go files: not those of testdata, vendor, or a directory holding a go.mod of its +// own (another directory of the level, linted there). +func lintable(dir string, each func(path string) error) error { + return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != dir { + switch d.Name() { + case "testdata", "vendor": + return filepath.SkipDir + } + if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { + return filepath.SkipDir + } + if strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + } + return nil + } + if strings.HasSuffix(path, ".go") { + return each(path) + } + return nil + }) +} + +// unformatted are the Go files of a directory gofmt would change, relative to the tree, and the build tags they name. +func unformatted(tree, d string) (files, tags []string, err error) { + seen := map[string]bool{} + err = lintable(filepath.Join(tree, d), func(path string) error { + src, err := os.ReadFile(path) + if err != nil { + return err + } + if out, ferr := format.Source(src); ferr != nil || !bytes.Equal(out, src) { + rel, _ := filepath.Rel(tree, path) + files = append(files, filepath.ToSlash(rel)) + } + scan := bufio.NewScanner(bytes.NewReader(src)) + for scan.Scan() { + line := strings.TrimSpace(scan.Text()) + if strings.HasPrefix(line, "package ") { + break + } + if expr, ok := strings.CutPrefix(line, "//go:build "); ok { + for _, tag := range buildTag.FindAllString(expr, -1) { + if !notATag[tag] && !strings.HasPrefix(tag, "go1") && !seen[tag] { + seen[tag] = true + tags = append(tags, tag) + } + } + } + } + return nil + }) + sort.Strings(files) + sort.Strings(tags) + return files, tags, err +} + +// lint is gofmt and go vet of one go.mod directory d of the tree, run with run in the directory's go environment; a +// failure of either is a Failure of the level naming what failed. +func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string, env []string) ([]Failure, error) { + files, tags, err := unformatted(tree, d) + if err != nil { + return nil, fmt.Errorf("reading the Go files of %s: %w", d, err) + } + var failed []Failure + if len(files) > 0 { + failed = append(failed, Failure{Package: d, Test: "gofmt", + Output: "not as gofmt writes them (run gofmt -w): " + strings.Join(files, ", ")}) + } + sets := [][]string{nil} + if len(tags) > 0 { + sets = append(sets, tags) + } + for _, set := range sets { + args := []string{"-C", filepath.Join(tree, d), "vet"} + name := "go vet" + if len(set) > 0 { + args = append(args, "-tags", strings.Join(set, ",")) + name += " -tags " + strings.Join(set, ",") + } + args = append(args, "./...") + r := run(ctx, env, "go", args...) + if r.Err != "" { + return nil, fmt.Errorf("%s in %s could not be run: %s", name, d, firstLine(r.Err+"\n"+r.Stderr)) + } + if r.Status != 0 { + failed = append(failed, Failure{Package: d, Test: name, Output: strings.TrimSpace(r.Stderr + "\n" + r.Stdout)}) + } + } + return failed, nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/lint_test.go b/modules/mesh-build-go/cmd/mesh-build-go/lint_test.go new file mode 100644 index 00000000..602d68b7 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/lint_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + tree := t.TempDir() + for name, body := range files { + p := filepath.Join(tree, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return tree +} + +// 1887/D7-go, ADR 0529 §1: an unformatted file fails the unit level naming the file (testdata and vendor are left +// alone), and go vet runs with no tag and with every tag the files name together, integration and isolation included, so +// a vet error behind isolation fails the level naming the run. +func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) { + tree := writeTree(t, map[string]string{ + "go.mod": "module x\n\ngo 1.26\n", + "a.go": "package x\n\nfunc A() {}\n", + "b.go": "package x\nfunc B() {}\n", + "in_integration_test.go": "//go:build integration\n\npackage x\n", + "iso.go": "//go:build integration && isolation && linux\n\npackage x\n", + "testdata/ugly.go": "package ugly\n", + "vendor/v/v.go": "package v\n", + "nested/go.mod": "module x/nested\n", + "nested/n.go": "package n\n", + }) + var vetted []string + run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + line := strings.Join(args[2:], " ") + vetted = append(vetted, line) + if strings.Contains(line, "isolation") { + return forgekey.Ran{Status: 1, Stderr: "iso.go:3: unreachable code"} + } + return forgekey.Ran{} + } + failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil) + if err != nil { + t.Fatal(err) + } + if strings.Join(vetted, " | ") != "vet ./... | vet -tags integration,isolation ./..." { + t.Errorf("vet ran %v", vetted) + } + if len(failed) != 2 || failed[0].Test != "gofmt" || !strings.Contains(failed[0].Output, "b.go") || + strings.Contains(failed[0].Output, "ugly") || strings.Contains(failed[0].Output, "n.go") || strings.Contains(failed[0].Output, "v.go") { + t.Fatalf("failed = %+v, want gofmt naming b.go alone", failed) + } + if failed[1].Test != "go vet -tags integration,isolation" || !strings.Contains(failed[1].Output, "unreachable") { + t.Errorf("failed = %+v, want vet with both tags naming its line", failed[1]) + } + // A formatted directory that vets passes. + clean := writeTree(t, map[string]string{"go.mod": "module y\n", "a.go": "package y\n"}) + if failed, err := (&Module{}).lint(context.Background(), func(context.Context, []string, string, ...string) forgekey.Ran { return forgekey.Ran{} }, clean, ".", nil); err != nil || len(failed) != 0 { + t.Errorf("a clean directory: %+v, %v", failed, err) + } +} + +// 1887/D7-go: through the step, the unit level of a tree holding an unformatted file fails, naming the file. +func TestAnUnformattedFileFailsTheUnitLevel(t *testing.T) { + m, states, ws, calls := workspaceFor(t, "empty") + tree := filepath.Join(m.Work, ws, "tree") + for name, body := range map[string]string{"go.mod": "module x\n", "x.go": "package x\nfunc X() {}\n"} { + if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + res, _ := runUnit(t, m, states, ws) + if res.Passed || len(res.Failed) != 1 || res.Failed[0].Test != "gofmt" || !strings.Contains(res.Failed[0].Output, "x.go") { + t.Fatalf("result = %+v, want the level failed naming x.go", res) + } + // Review round 2 item 5: before the level's tests, which do not run once gofmt failed. + for _, c := range *calls { + if strings.Contains(c.line, " test -json") { + t.Errorf("the tests ran after gofmt failed: %s", c.line) + } + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index 85296b47..3becd9d0 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -52,6 +52,8 @@ func main() { } m.Run = StepRunner(m.Home) m.RegistryFile = m.Home + "/registry" + m.Settings = m.Home + "/settings.json" + m.ReadKept = readOf(stdio.State(KeptState)).Get m.Probe = ProbeGo(m.Home, m.Cache) m.Bubblewrap = ProbeBubblewrap // Outside a launch by the runtime — a build, a check — there is no node and no state to write. @@ -64,7 +66,13 @@ func main() { logf("announcing the states it keeps: %v", err) os.Exit(1) } - if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil { + steps := m.StepTools() + puller := m.Puller(steps, nil) + if m.Node != "" { + // The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again. + go func() { _ = puller.Pull(ctx) }() + } + if err := stdio.Serve("", append(m.Tools(steps), m.SeatTools(puller)...)); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go index 2791451e..7ddfe322 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go @@ -84,9 +84,10 @@ func TestWorkAndCacheDirectoriesLieOutsideTheHome(t *testing.T) { } } -// The states are the three the module keeps, no seat is claimed (ADR 0469 §6), and the health is the module's own -// tool, which the manifest also lists among its tools. -func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) { +// The states are the three the module keeps, the one seat it claims is its language's build seat (ADR 0525 §2, which +// changes ADR 0469 §6's "no seat"), and the health is the module's own tool, which the manifest also lists among its +// tools. +func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *testing.T) { m := readManifest(t) states := []string{} for _, s := range m.State { @@ -96,8 +97,8 @@ func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) { t.Errorf("state = %v, want %v", states, want) } - if len(m.Claims) != 0 { - t.Errorf("claims = %v, want no seat", m.Claims) + if len(m.Claims) != 1 || m.Claims[0].Name != Seat { + t.Errorf("claims = %v, want the seat %s alone", m.Claims, Seat) } if !slices.Contains(m.Tools, HealthTool) { t.Errorf("tools = %v lack %s", m.Tools, HealthTool) @@ -175,3 +176,12 @@ func TestTheModuleRequiresTheArtifactStoreAndIsToldItsAddress(t *testing.T) { t.Errorf("files/registry is %q (%v), want the store seat's reach", raw, err) } } + +// 1911: the module pulls its asks through node-tools' seat pull (mesh-tools, R4g), so it requires node-tools on its node +// and the delivery plan puts node-tools first: a node's build module never pulls through a tool runner that cannot. +func TestTheModuleRequiresNodeToolsItPullsThrough(t *testing.T) { + m := readManifest(t) + if !slices.Contains(m.Requires, "node-tools") { + t.Errorf("requires %v lack node-tools", m.Requires) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index 0f306017..3db6e3fb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "net/http" "sync" @@ -76,6 +77,12 @@ type Module struct { // floorOf(node), and a zero Floor checks nothing. Floor buildstep.Floor + // Settings is the module's settings file, which holds the node's build capacity (buildstep.CapacitySetting); Capacity, + // when set, answers it instead (a test). ReadKept reads a key of the checker's kept state (KeptState). + Settings string + Capacity func() int + ReadKept func(key string) (json.RawMessage, error) + spacesOnce sync.Once workspaces *buildstep.Workspaces } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index c2b8167b..d3b42c9e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -197,6 +197,26 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR todo = append(todo, d) } } + if o.level == LevelUnit { + // gofmt and go vet of each directory the level runs come before its tests (ADR 0529 §1; issue 1887 D7-go): a + // directory that fails them fails the level at once, and their time is the result's Lint. + linted := m.clock() + var failed []Failure + for _, d := range todo { + f, err := m.lint(ctx, run, tree, d, append(m.goEnvFor(filepath.Join(tree, d)), o.env...)) + if err != nil { + return nil, err + } + failed = append(failed, f...) + } + res.Lint = m.clock().Sub(linted).Seconds() + if len(failed) > 0 { + res.Failed = failed + res.judge(o.bounds) + return res, nil + } + began = m.clock() // the level's running time is its tests' + } runs := make([]dirRun, len(todo)) slots := make(chan struct{}, m.parallel()) var wg sync.WaitGroup diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go index 26c7a75f..78a5a8f6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go @@ -66,7 +66,7 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) { "tagged": {passed: true, noTests: true}, "empty": {passed: true, noTests: true}, "slow": {passed: true}, - "broken": {failed: "fixture/broken"}, + "broken": {failed: "."}, // go vet, before the tests since R4g (ADR 0529 §1), names the directory and the compiler's line } for name, w := range want { home := t.TempDir() @@ -90,6 +90,32 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) { } } +// 1887/D7-go against the real go: a tree holding an unformatted file fails the unit level naming the file, before any +// test runs, and the level's result carries the lint's time. +func TestAnUnformattedFileFailsTheRealUnitLevel(t *testing.T) { + needTools(t, "the unit level's gofmt and vet", "go", "git", "bwrap") + home := t.TempDir() + m := &Module{Home: home, Work: t.TempDir(), Cache: t.TempDir()} + tree := filepath.Join(m.Work, "ws1", "tree") + if err := os.MkdirAll(filepath.Dir(tree), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Rename(repository(t, "slow"), tree); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tree, "ugly.go"), []byte("package slow\nfunc Ugly() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Run = StepRunner(home) + res, err := m.runLevel(context.Background(), tree, levelOpts{workspace: "ws1", level: LevelUnit, bounds: Bounds{Run: UnitBound}}) + if err != nil { + t.Fatal(err) + } + if res.Passed || len(res.Failed) == 0 || res.Failed[0].Test != "gofmt" || !strings.Contains(res.Failed[0].Output, "ugly.go") || res.Tests != 0 { + t.Fatalf("result = %+v, want the level failed by gofmt naming ugly.go and no test run", res) + } +} + // processesNamed are the processes whose command line holds text. func processesNamed(text string) []string { var out []string diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go index 39cab882..359ce3c4 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go @@ -30,6 +30,8 @@ func capturedRunner(t *testing.T, captured string, calls *[]cmdRecord) forgekey. switch { case name == "git" && args[2] == "ls-files": return forgekey.Ran{Stdout: "go.mod\n"} + case name == "go" && len(args) > 2 && args[2] == "vet": + return forgekey.Ran{} // vet passes: the fixtures are vetted (lint_test.go) case name == "go": return forgekey.Ran{Stdout: string(raw), Status: status} } @@ -167,7 +169,7 @@ func TestUnitLevelRunsGoTestJsonInTheModulesDirectoryWithTheStepsEnvironment(t * var line string var env string for _, c := range *calls { - if strings.HasPrefix(c.line, "go ") { + if strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, " test ") { line, env = c.line, strings.Join(c.env, " ") } } @@ -186,6 +188,9 @@ func TestUnitLevelRunsGoTestJsonInTheModulesDirectoryWithTheStepsEnvironment(t * func TestAGoTestThatCouldNotRunEndsTheStepFailed(t *testing.T) { m, states, ws, _ := workspaceFor(t, "empty") m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + if name == "go" && len(args) > 2 && args[2] == "vet" { + return forgekey.Ran{} // vet, before the tests, passes: this test is of go test that could not run + } if name == "go" { return forgekey.Ran{Status: 1, Stderr: "go: cannot find main module\n"} } diff --git a/modules/mesh-build-go/files/settings.json b/modules/mesh-build-go/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/modules/mesh-build-go/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/modules/mesh-build-go/go.mod b/modules/mesh-build-go/go.mod index e63fc0a5..ddc741f5 100644 --- a/modules/mesh-build-go/go.mod +++ b/modules/mesh-build-go/go.mod @@ -3,7 +3,7 @@ module meshbuildgo go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023121526-2248b985bfac + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 github.com/distribution/distribution/v3 v3.1.2 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/modules/mesh-build-go/go.sum b/modules/mesh-build-go/go.sum index 868be86a..ce58bc63 100644 --- a/modules/mesh-build-go/go.sum +++ b/modules/mesh-build-go/go.sum @@ -1,11 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023120306-780be2fae5a1 h1:MqP6mWWVxM+niUGPxSSh4Q8lduYkkQ7oTAkDeC9nyv4= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023120306-780be2fae5a1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023121526-2248b985bfac h1:xx601bHwpO/aq5KyE08t4ymSXGHGnk+D9VHfmOq+5P8= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023121526-2248b985bfac/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index b78bc91a..4d2b080a 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -12,6 +12,24 @@ "keys", "steps" ], + "claims": [ + { + "name": "node-build-go", + "scope": "node", + "serves": ["current", "pause", "resume"] + } + ], + "reads": [ + "mesh-delivery-checker.kept" + ], + "config": { + "capacity": { + "type": "string", + "description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", + "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", + "default": "shanks=3,ace=2,g14=0,novox=0" + } + }, "tools": [ "build_go_fetch", "build_go_test", @@ -33,7 +51,8 @@ "requires": [ "forge-account", "go-toolchain", - "artifact-store" + "artifact-store", + "node-tools" ], "own-secrets": { "broker": "${dir:state}/broker" @@ -79,6 +98,17 @@ "owner": "mesh-build-go", "unknown": "program" }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "mesh-build-go", + "takes-config": ["capacity"], + "merge": "json", + "template": "files/settings.json" + }, { "id": "ssh-dir", "type": "directory", diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks.go b/modules/mesh-build-image/cmd/mesh-build-image/asks.go new file mode 100644 index 00000000..e72a4d05 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **The module pulls its seat's asks** (novox/hq ADR 0525 §2-§6; issue 1887 A3image), as mesh-build-go does: the seat +// node-build-image, at most the node's build capacity at once (its setting `capacity`); a delivery or check ask is a build +// (fetch, build, or copy of a pinned image), never a check of tests. The tools stay as the hand path (ADR 0525 §8). + +// Seat is the node seat this module holds. +const Seat = "node-build-image" + +func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { + byName := map[string]buildstep.StepFunc{} + for _, s := range steps { + run := s.Run + byName[s.Name] = func(ctx context.Context, args map[string]any) (any, error) { + defer m.holdArg(args)() + return run(ctx, args) + } + } + return buildstep.AskSteps{ + Fetch: byName["build_image_fetch"], Build: byName["build_image_build"], Copy: byName["build_image_copy"], + Release: func(ws string) error { return m.spaces().Release(ws) }, + Logf: m.Say, + } +} + +// Puller is the module's pull of its seat's asks, on source (the runtime's seat pull when nil). +func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep.Puller { + if source == nil { + source = buildstep.SeatAsks{Language: delivery.AskImage} + } + capacity := m.Capacity + if capacity == nil { + capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + } + ask := m.askSteps(steps) + var load func(string) ([]byte, error) + if m.ReadSteps.Get != nil { + load = func(key string) ([]byte, error) { return m.ReadSteps.Get(key) } + } + return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, Load: load, + Capacity: capacity, Logf: m.Say, + Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + if err := m.Check(ctx); err != nil { + return nil, err + } + if err := m.room(); err != nil { + return nil, err + } + return buildstep.RunAsk(ctx, ask, a) + }} +} + +// SeatTools are the seat's verbs on this node (ADR 0525 §10). +func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { + return buildstep.SeatTools(Seat, "image", p) +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go b/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go new file mode 100644 index 00000000..46105474 --- /dev/null +++ b/modules/mesh-build-image/cmd/mesh-build-image/asks_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const askCommit = "0123456789abcdef0123456789abcdef01234567" + +// 1887/A3image: a pulled image ask copies a pinned image with no fetch, then fetches and builds the module's own, and its +// key . names each reference; the seat's verbs are served. +func TestAPulledImageAskCopiesThenBuilds(t *testing.T) { + states := newStates() + m := moduleWith(t, newFake(t), states) + m.Capacity = func() int { return 1 } + var calls []string + answer := func(name string, out any) StepTool { + return StepTool{Name: name, Run: func(ctx context.Context, args map[string]any) (any, error) { + calls = append(calls, name) + return out, nil + }} + } + steps := []StepTool{ + answer("build_image_fetch", map[string]any{"workspace": "ws1"}), + answer("build_image_build", map[string]any{"digest": "sha256:cc", "reference": "registry/m/server@sha256:cc"}), + answer("build_image_copy", map[string]any{"digest": "sha256:dd", "reference": "registry/m/base@sha256:dd"}), + } + src := buildstep.NewFakeAsks() + a := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskDelivery, Language: delivery.AskImage, Asker: "mesh-delivery-builder", + At: time.Now(), Build: &delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true, + Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit}, + Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "docker.io/library/alpine@sha256:00"}, {Artifact: "server"}}}} + _ = src.PublishAsk(a) + p := m.Puller(steps, src) + p.PullWait = 10 * time.Millisecond + ctx, stop := context.WithCancel(context.Background()) + defer stop() + go p.Pull(ctx) + key, _ := delivery.AskStepKey("ace", a.ID) + var step delivery.Step + for deadline := time.Now().Add(3 * time.Second); ; time.Sleep(5 * time.Millisecond) { + states.mu.Lock() + v := states.steps[key] + states.mu.Unlock() + if len(v) > 0 && v[len(v)-1].State != delivery.StepRunning { + step = v[len(v)-1] + break + } + if time.Now().After(deadline) { + t.Fatalf("the ask's step never ended: calls %v", calls) + } + } + if step.State != delivery.StepPassed || !strings.Contains(string(step.Result), "registry/m/server@sha256:cc") || + !strings.Contains(string(step.Result), "registry/m/base@sha256:dd") { + t.Fatalf("the step = %+v", step) + } + if got := strings.Join(calls, ","); got != "build_image_copy,build_image_fetch,build_image_build" { + t.Errorf("calls = %s", got) + } + served := map[string]bool{} + for _, tool := range m.SeatTools(p) { + served[tool.Name] = true + } + for _, verb := range []string{"current", "pause", "resume"} { + if !served[Seat+"."+verb] { + t.Errorf("%s.%s is not served", Seat, verb) + } + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go b/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go index 04f9a2ed..6ae5a655 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/compose_test.go @@ -50,10 +50,11 @@ func TestTheModuleComposesWhereItsFilesAreHandedToTheProvisionersClient(t *testi "buildah": read("buildah"), "forge": {Module: "forge", Version: "1", Provides: []catalogue.Offer{{Name: "forge-account"}}}, "store": {Module: "store", Version: "1", Provides: []catalogue.Offer{{Name: "artifact-store"}}}, + "runner": {Module: "runner", Version: "1", Provides: []catalogue.Offer{{Name: "node-tools"}}}, // node-tools, which the module pulls its asks through (1911) } node := catalogue.Node{Name: "builder", Site: "house", Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}} - r, err := catalogue.Resolve(shelf, []string{"forge", "store", "buildah", "mesh-build-image"}, node, catalogue.World{}) + r, err := catalogue.Resolve(shelf, []string{"forge", "store", "runner", "buildah", "mesh-build-image"}, node, catalogue.World{}) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go b/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go index 99107658..71c952ef 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/forge_test.go @@ -144,7 +144,7 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { // template in the files/ archive (novox/hq ADR 0349 §2). func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) - if want := []any{"forge-account", "rootless-image-build", "artifact-store"}; !reflect.DeepEqual(m.Requires, want) { + if want := []any{"forge-account", "rootless-image-build", "artifact-store", "node-tools"}; !reflect.DeepEqual(m.Requires, want) { t.Errorf("requires = %v, want %v", m.Requires, want) } keeps := false diff --git a/modules/mesh-build-image/cmd/mesh-build-image/main.go b/modules/mesh-build-image/cmd/mesh-build-image/main.go index 169d9b10..1fc279c3 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/main.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/main.go @@ -42,6 +42,7 @@ func main() { m.Home, m.Account, m.UID = u.HomeDir, u.Username, u.Uid } m.RegistryFile = m.Home + "/registry" + m.Settings = m.Home + "/settings.json" m.Run = StepRunner(m.Home) // Outside a launch by the runtime — a build, a check — there is no node and no state to write. if m.Node != "" { @@ -53,7 +54,13 @@ func main() { logf("announcing the states it keeps: %v", err) os.Exit(1) } - if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil { + steps := m.StepTools() + puller := m.Puller(steps, nil) + if m.Node != "" { + // The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again. + go func() { _ = puller.Pull(ctx) }() + } + if err := stdio.Serve("", append(m.Tools(steps), m.SeatTools(puller)...)); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go index 5fa56c06..b8be9de0 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/manifest_test.go @@ -90,9 +90,9 @@ func TestWorkAndStoreDirectoriesLieOutsideTheHome(t *testing.T) { } } -// The states are the two the module keeps, no seat is claimed, the health is its own tool, which the manifest also lists, +// The states are the two the module keeps, the one seat it claims is its build seat (ADR 0525 §2), the health is its own tool, which the manifest also lists, // and the registry's address is bound into a file of the home that the program reads. -func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) { +func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *testing.T) { m := readManifest(t) states := []string{} for _, s := range m.State { @@ -102,8 +102,8 @@ func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T if want := []string{"keys", "steps"}; !slices.Equal(states, want) { t.Errorf("state = %v, want %v", states, want) } - if len(m.Claims) != 0 { - t.Errorf("claims = %v, want no seat", m.Claims) + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) { + t.Errorf("claims = %v, want the seat %s alone", m.Claims, Seat) } raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json")) if !strings.Contains(string(raw), `"tool": "`+HealthTool+`"`) { @@ -163,6 +163,8 @@ func TestACompositionWithoutBuildahIsRefusedNamingItAndWithBuildahComposes(t *te shelf := map[string]catalogue.Manifest{ "forge": {Module: "forge", Version: "1", Provides: []catalogue.Offer{{Name: "forge-account"}}}, "store": {Module: "store", Version: "1", Provides: []catalogue.Offer{{Name: "artifact-store"}}}, + // node-tools, the tool runner the module pulls its asks through (1911), stood in for as the others are. + "runner": {Module: "runner", Version: "1", Provides: []catalogue.Offer{{Name: "node-tools"}}}, } for _, name := range []string{"mesh-build-image", "buildah", "docker"} { m, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", name)) @@ -173,7 +175,7 @@ func TestACompositionWithoutBuildahIsRefusedNamingItAndWithBuildahComposes(t *te } node := catalogue.Node{Name: "builder", Site: "house", Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}} - _, err := catalogue.Resolve(shelf, []string{"forge", "store", "docker", "mesh-build-image"}, node, catalogue.World{}) + _, err := catalogue.Resolve(shelf, []string{"forge", "store", "runner", "docker", "mesh-build-image"}, node, catalogue.World{}) if err == nil { t.Fatal("mesh-build-image composed on a machine without buildah (docker does not stand in for it)") } @@ -182,7 +184,16 @@ func TestACompositionWithoutBuildahIsRefusedNamingItAndWithBuildahComposes(t *te t.Errorf("the refusal does not say %q:\n%v", want, err) } } - if _, err := catalogue.Resolve(shelf, []string{"forge", "store", "buildah", "mesh-build-image"}, node, catalogue.World{}); err != nil { + if _, err := catalogue.Resolve(shelf, []string{"forge", "store", "runner", "buildah", "mesh-build-image"}, node, catalogue.World{}); err != nil { t.Errorf("with buildah assigned the composition is refused: %v", err) } } + +// 1911: the module pulls its asks through node-tools' seat pull (mesh-tools, R4g), so it requires node-tools on its node +// and the delivery plan puts node-tools first: a node's build module never pulls through a tool runner that cannot. +func TestTheModuleRequiresNodeToolsItPullsThrough(t *testing.T) { + m := readManifest(t) + if !slices.Contains(m.Requires, "node-tools") { + t.Errorf("requires %v lack node-tools", m.Requires) + } +} diff --git a/modules/mesh-build-image/cmd/mesh-build-image/module.go b/modules/mesh-build-image/cmd/mesh-build-image/module.go index 1f3f206f..71d3d1af 100644 --- a/modules/mesh-build-image/cmd/mesh-build-image/module.go +++ b/modules/mesh-build-image/cmd/mesh-build-image/module.go @@ -50,6 +50,11 @@ type Module struct { // 1839); main sets floorOf(node), and a zero Floor checks nothing. Floor buildstep.Floor + // Settings is the module's settings file, which holds the node's build capacity (buildstep.CapacitySetting); Capacity, + // when set, answers it instead (a test). + Settings string + Capacity func() int + spacesOnce sync.Once workspaces *buildstep.Workspaces } diff --git a/modules/mesh-build-image/files/settings.json b/modules/mesh-build-image/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/modules/mesh-build-image/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/modules/mesh-build-image/go.mod b/modules/mesh-build-image/go.mod index a1456ae4..e1c49713 100644 --- a/modules/mesh-build-image/go.mod +++ b/modules/mesh-build-image/go.mod @@ -3,7 +3,7 @@ module meshbuildimage go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-build-image/go.sum b/modules/mesh-build-image/go.sum index 4ae4817b..2a3e9f34 100644 --- a/modules/mesh-build-image/go.sum +++ b/modules/mesh-build-image/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-build-image/module.json b/modules/mesh-build-image/module.json index 1f1f86de..cfe8489f 100644 --- a/modules/mesh-build-image/module.json +++ b/modules/mesh-build-image/module.json @@ -11,6 +11,21 @@ "keys", "steps" ], + "claims": [ + { + "name": "node-build-image", + "scope": "node", + "serves": ["current", "pause", "resume"] + } + ], + "config": { + "capacity": { + "type": "string", + "description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", + "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", + "default": "shanks=3,ace=2,g14=0,novox=0" + } + }, "tools": [ "build_image_fetch", "build_image_build", @@ -24,7 +39,8 @@ "requires": [ "forge-account", "rootless-image-build", - "artifact-store" + "artifact-store", + "node-tools" ], "own-secrets": { "broker": "${dir:state}/broker" @@ -54,6 +70,17 @@ "place": ".", "unknown": "program" }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "mesh-build-image", + "takes-config": ["capacity"], + "merge": "json", + "template": "files/settings.json" + }, { "id": "work", "type": "directory", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks.go b/modules/mesh-build-node/cmd/mesh-build-node/asks.go new file mode 100644 index 00000000..fa3becaf --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **The module pulls its seat's asks** (novox/hq ADR 0525 §2-§6, ADR 0536 §4; issue 1887 A3node), as mesh-build-go +// does: the seat node-build-node, delivery first, then check, then recheck, at most the node's build capacity at once +// (its setting `capacity`); each ask runs with the step bodies its tools run, behind the same guard, and ends on the key +// .. The tools stay as the hand path (ADR 0525 §8). + +// Seat is the node seat this module holds. +const Seat = "node-build-node" + +func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { + byName := map[string]buildstep.StepFunc{} + for _, s := range steps { + run := s.Run + byName[s.Name] = func(ctx context.Context, args map[string]any) (any, error) { + defer m.holdArg(args)() + return run(ctx, args) + } + } + return buildstep.AskSteps{ + Fetch: byName["build_node_fetch"], Build: byName["build_node_build"], Test: byName["build_node_test"], + Toolchain: func(ctx context.Context) (delivery.ToolchainUsed, error) { + installed, err := m.Probe(ctx) + if err != nil { + return delivery.ToolchainUsed{}, err + } + return delivery.ToolchainUsed{Version: installed, Recipe: m.recipe().Version()}, nil + }, + Release: func(ws string) error { return m.spaces().Release(ws) }, + Logf: m.Say, + } +} + +// Puller is the module's pull of its seat's asks, on source (the runtime's seat pull when nil). +func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep.Puller { + if source == nil { + source = buildstep.SeatAsks{Language: delivery.AskNode} + } + capacity := m.Capacity + if capacity == nil { + capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) + } + ask := m.askSteps(steps) + var load func(string) ([]byte, error) + if m.ReadSteps.Get != nil { + load = func(key string) ([]byte, error) { return m.ReadSteps.Get(key) } + } + return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, Load: load, + Capacity: capacity, Logf: m.Say, + Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { + if err := m.Prepare(ctx); err != nil { + return nil, err + } + if err := m.room(); err != nil { + return nil, err + } + return buildstep.RunAsk(ctx, ask, a) + }} +} + +// SeatTools are the seat's verbs on this node (ADR 0525 §10). +func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { + return buildstep.SeatTools(Seat, "Node", p) +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go new file mode 100644 index 00000000..1985c863 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/asks_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const askCommit = "0123456789abcdef0123456789abcdef01234567" + +func fakeSteps(calls *[]string) []StepTool { + answer := func(name string, out any) StepTool { + return StepTool{Name: name, Run: func(ctx context.Context, args map[string]any) (any, error) { + *calls = append(*calls, name) + return out, nil + }} + } + return []StepTool{ + answer("build_node_fetch", map[string]any{"workspace": "ws1", "modules": []string{"modules/gitea"}}), + answer("build_node_build", map[string]any{"digest": "sha256:bb", "reference": "artifact-store://gitea/code@sha256:bb"}), + answer("build_node_test", map[string]any{"passed": true}), + } +} + +func nodeAsk(kind string) delivery.StepAsk { + a := delivery.StepAsk{ID: buildstep.NewID(), Kind: kind, Language: delivery.AskNode, Asker: "mesh-delivery-checker", At: time.Now()} + fetch := map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit} + if kind == delivery.AskDelivery { + a.Asker = "mesh-delivery-builder" + a.Build = &delivery.BuildJob{Module: "gitea", Path: "modules/gitea", Fetch: fetch, Artifacts: []delivery.AskArtifact{{Artifact: "code"}}} + } else { + a.Check = &delivery.CheckJob{Fetch: fetch, Levels: []string{delivery.LevelUnit}} + } + return a +} + +// 1887/A3node: a pulled build ask runs fetch then build and its key names the reference; a check ask tests each module +// directory the fetch named. +func TestAPulledNodeAskRunsItsJob(t *testing.T) { + states := newStates() + m := moduleWith(states, "v26.10.0") + m.Capacity = func() int { return 1 } + var calls []string + src := buildstep.NewFakeAsks() + build, check := nodeAsk(delivery.AskDelivery), nodeAsk(delivery.AskCheck) + _ = src.PublishAsk(check) + _ = src.PublishAsk(build) + p := m.Puller(fakeSteps(&calls), src) + p.PullWait = 10 * time.Millisecond + ctx, stop := context.WithCancel(context.Background()) + defer stop() + go p.Pull(ctx) + for deadline := time.Now().Add(3 * time.Second); ; time.Sleep(5 * time.Millisecond) { + if acked, _, _ := src.Fates(); len(acked) == 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("the asks were not both acked: calls %v", calls) + } + } + if got := strings.Join(calls, ","); got != "build_node_fetch,build_node_build,build_node_fetch,build_node_test" { + t.Errorf("calls = %s, want the delivery ask's job first, then the check's", got) + } + key, _ := delivery.AskStepKey("ace", build.ID) + states.mu.Lock() + v := states.steps[key] + states.mu.Unlock() + if len(v) == 0 || !strings.Contains(string(v[len(v)-1].Result), "artifact-store://gitea/code@sha256:bb") { + t.Fatalf("the step %s = %v", key, v) + } +} + +// 1887/A3node, A7: the manifest takes the node's build capacity as a setting, with the builder's default, in a settings +// file; the seat's verbs are served. +func TestManifestTakesTheBuildCapacity(t *testing.T) { + m := readManifest(t) + raw, _ := os.ReadFile(moduleDir + "/module.json") + var doc struct { + Config map[string]struct { + Default string `json:"default"` + } `json:"config"` + } + _ = json.Unmarshal(raw, &doc) + if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities { + t.Errorf("the capacity's default = %q", doc.Config[buildstep.CapacitySetting].Default) + } + if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" { + t.Error("no settings file") + } + m0 := &Module{Node: "ace", Capacity: func() int { return 0 }} + served := map[string]bool{} + for _, tool := range m0.SeatTools(m0.Puller(nil, buildstep.NewFakeAsks())) { + served[tool.Name] = true + } + for _, verb := range []string{"current", "pause", "resume"} { + if !served[Seat+"."+verb] { + t.Errorf("%s.%s is not served", Seat, verb) + } + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index b6b1f30d..0856bd77 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -55,6 +55,7 @@ func main() { RegistryFile: filepath.Join(home, "registry"), } m.Floor = floorOf(m.Node) + m.Settings = filepath.Join(home, "settings.json") // Outside a launch by the runtime — a build, a check — there is no node and no state to write. if m.Node != "" { go m.Start(ctx) @@ -65,7 +66,13 @@ func main() { logf("announcing the states it keeps: %v", err) os.Exit(1) } - if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil { + steps := m.StepTools() + puller := m.Puller(steps, nil) + if m.Node != "" { + // The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again. + go func() { _ = puller.Pull(ctx) }() + } + if err := stdio.Serve("", append(m.Tools(steps), m.SeatTools(puller)...)); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index 74c56cff..8b9ff5ec 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -86,9 +86,10 @@ func TestWorkAndCacheDirectoriesLieOutsideTheHome(t *testing.T) { } } -// The states are the three the module keeps, no seat is claimed (ADR 0469 §6), and the health is the module's own -// tool, which the manifest also lists among its tools. -func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) { +// The states are the three the module keeps, the one seat it claims is its language's build seat (ADR 0525 §2, which +// changes ADR 0469 §6's "no seat"), and the health is the module's own tool, which the manifest also lists among its +// tools. +func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *testing.T) { m := readManifest(t) states := []string{} for _, s := range m.State { @@ -98,8 +99,8 @@ func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) { t.Errorf("state = %v, want %v", states, want) } - if len(m.Claims) != 0 { - t.Errorf("claims = %v, want no seat", m.Claims) + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) { + t.Errorf("claims = %v, want the seat %s alone", m.Claims, Seat) } if !slices.Contains(m.Tools, HealthTool) { t.Errorf("tools = %v lack %s", m.Tools, HealthTool) @@ -238,3 +239,12 @@ func TestTheModuleContributesWriteAccessToThePackageRegistry(t *testing.T) { t.Errorf("npm-package-registry access = %v, want write", got) } } + +// 1911: the module pulls its asks through node-tools' seat pull (mesh-tools, R4g), so it requires node-tools on its node +// and the delivery plan puts node-tools first: a node's build module never pulls through a tool runner that cannot. +func TestTheModuleRequiresNodeToolsItPullsThrough(t *testing.T) { + m := readManifest(t) + if !slices.Contains(m.Requires, "node-tools") { + t.Errorf("requires %v lack node-tools", m.Requires) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index 65660894..a464a88e 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -63,6 +63,11 @@ type Module struct { // sets floorOf(node), and a zero Floor checks nothing. Floor buildstep.Floor + // Settings is the module's settings file, which holds the node's build capacity (buildstep.CapacitySetting); Capacity, + // when set, answers it instead (a test). + Settings string + Capacity func() int + spacesOnce sync.Once workspaces *buildstep.Workspaces mu sync.Mutex // guards Recipe diff --git a/modules/mesh-build-node/files/settings.json b/modules/mesh-build-node/files/settings.json new file mode 100644 index 00000000..3175fe39 --- /dev/null +++ b/modules/mesh-build-node/files/settings.json @@ -0,0 +1,3 @@ +{ + "capacity": "shanks=3,ace=2,g14=0,novox=0" +} diff --git a/modules/mesh-build-node/go.mod b/modules/mesh-build-node/go.mod index 42810236..be5cff1c 100644 --- a/modules/mesh-build-node/go.mod +++ b/modules/mesh-build-node/go.mod @@ -3,7 +3,7 @@ module meshbuildnode go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 github.com/distribution/distribution/v3 v3.1.2 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-build-node/go.sum b/modules/mesh-build-node/go.sum index ff19eb14..0ac41f85 100644 --- a/modules/mesh-build-node/go.sum +++ b/modules/mesh-build-node/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index ae14c688..25f858eb 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -12,6 +12,21 @@ "keys", "steps" ], + "claims": [ + { + "name": "node-build-node", + "scope": "node", + "serves": ["current", "pause", "resume"] + } + ], + "config": { + "capacity": { + "type": "string", + "description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", + "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", + "default": "shanks=3,ace=2,g14=0,novox=0" + } + }, "tools": [ "build_node_fetch", "build_node_test", @@ -35,7 +50,8 @@ "forge-account", "npm-package-registry", "node-toolchain", - "artifact-store" + "artifact-store", + "node-tools" ], "contributes": { "npm-package-registry": { @@ -76,6 +92,17 @@ "place": ".", "unknown": "program" }, + { + "id": "settings", + "trusted": false, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "mesh-build-node", + "takes-config": ["capacity"], + "merge": "json", + "template": "files/settings.json" + }, { "id": "work", "type": "directory", diff --git a/modules/mesh-catalog/heard.ts b/modules/mesh-catalog/heard.ts index 4dab234f..eaa8f882 100644 --- a/modules/mesh-catalog/heard.ts +++ b/modules/mesh-catalog/heard.ts @@ -47,12 +47,8 @@ export interface Built { /** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */ export const BUILD_EVENTS = [ - // **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the - // retired seat below, so every build after the handover reached the graph only through the replay this - // module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start. - "node-build-agent.built", - // The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row. - "mesh-build-machine.built", + // The build seat node-build-agent and the retired mesh-build-machine went with build-agent (novox/hq ADR 0524 §5, + // ADR 0525 §10): no build seat says `built` since R4g, so what the mesh holds is asked when this module starts. // What the mesh already held, when this module asks what it missed (novox/hq ADR 0134). "mesh-controller.built-before", ] as const; diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 8d8c6599..b8f45096 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -26,8 +26,6 @@ "postgres-database": "${dir:state}/database.secret" }, "consumes": [ - "node-build-agent.built", - "mesh-build-machine.built", "mesh-controller.built-before" ], "emits": [ diff --git a/modules/mesh-catalog/test/heard.test.ts b/modules/mesh-catalog/test/heard.test.ts index e00daa1b..84b91ac4 100644 --- a/modules/mesh-catalog/test/heard.test.ts +++ b/modules/mesh-catalog/test/heard.test.ts @@ -20,8 +20,10 @@ const walkBuild = { made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }], }; -test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => { - assert.ok(BUILD_EVENTS.includes("node-build-agent.built")); +test("the catalogue hears no retired build seat, and what the mesh held (issue 712, issue 1887 E2)", async () => { + for (const retired of ["node-build-agent.built", "mesh-build-machine.built"]) { + assert.ok(!(BUILD_EVENTS as readonly string[]).includes(retired), `${retired} is still heard`); + } assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before")); // Subscribing is not enough: the mesh grants and routes only what the manifest consumes. const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_bus_test.go index 0bde5945..1713b073 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_bus_test.go @@ -3,14 +3,10 @@ package main import ( - "context" "encoding/json" "strconv" - "strings" "testing" "time" - - sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) // novox/hq ADR 0505 §4 over the real bus, in the bed of bus_test.go: the gate whole, its deliveries kept in its buckets. @@ -33,56 +29,3 @@ func (r *runtime) call(t *testing.T, name string, args map[string]any) string { } return "" } - -// ADR 0505 §4: an approved head the merge tool refused because its trunk moved is shown by `show` at once but is not in -// `stalled` while its fresh check is unanswered; once that check passed and the merge is refused again, it is. -func TestATrunkMovedRefusalIsStalledOnlyOnceItsFreshCheckDidNotCureItOverARealBus(t *testing.T) { - b := newTestBus(t) - ctx := context.Background() - rt := b.start(t) - evctx, cancel := context.WithCancel(ctx) - defer cancel() - rt.deliverEvents(evctx) - rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool { - rt.mu.Lock() - defer rt.mu.Unlock() - return len(rt.subs) >= len(followed) - }) - rt.untilHolding() - - const head = "1111111111111111111111111111111111111111" - id := IDOf("novox/app", head) - announced := pr("novox/app", 7, head, "fix/x", "src/a.go") - announced.BaseSHA = "t1t1t1t1t1t1" - b.publish(t, "mesh.mod.gitea.event.pull.updated", announced) - b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1")) - var key string - rt.until("the ask to be written", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" }) - b.answer(t, key, OptionApprove) - rt.until("the merge to be handed to the bridge", 10*time.Second, func() bool { return len(mergeAt(b, "fix/x").Pulls) > 0 }) - - refused := func() { - b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeRefused, Act: "merge", Repository: "novox/app", Number: 7, Head: head, - Words: errAskedAgain.Error()}) - } - refused() - rt.until("show to name the refusal at once", 10*time.Second, func() bool { - return strings.Contains(rt.call(t, Seat+".show", map[string]any{"id": id}), "its verdict was judged against trunk") - }) - if got := rt.call(t, Seat+".stalled", map[string]any{}); strings.Contains(got, id) { - t.Fatalf("said as stalled while its fresh check runs: %s", got) - } - - // The trunk moved; the head is announced again and its fresh check passes; the merge is refused again on it. - b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, "9999999999999999999999999999999999999999", "t2t2t2t2t2t2")) - b.publish(t, "mesh.mod.gitea.event.pull.updated", announced) - time.Sleep(time.Second) - b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t2t2t2t2t2t2")) - rt.until("the fresh verdict to be heard", 10*time.Second, func() bool { - return strings.Contains(rt.call(t, Seat+".show", map[string]any{"id": id}), "t2t2t2t2t2t2") - }) - refused() - rt.until("the refusal on the fresh verdict to be said as stalled", 10*time.Second, func() bool { - return strings.Contains(rt.call(t, Seat+".stalled", map[string]any{}), id) - }) -} 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 76833d2f..40f67468 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 @@ -95,39 +95,6 @@ func linesFor(w *world, id string) []StalledLine { return out } -// ADR 0505 §4: a refusal because the trunk moved, whose check the gate asked again, is in the gate's own list at once -// but is no stalled line while its check is unanswered; once that check passed and the merge is refused again, it is. -func TestATrunkMovedRefusalIsStalledOnlyOnceItsFreshCheckAnsweredAndTheMergeIsRefusedAgain(t *testing.T) { - w, id := refusedForAMovedTrunk(t) - if got := linesFor(w, id); len(got) != 0 { - t.Fatalf("said as stalled while its fresh check runs: %+v", got) - } - shown, err := w.h.Show(id) - if err != nil { - t.Fatal(err) - } - if raw, _ := json.Marshal(shown); !strings.Contains(string(raw), "is not merged: its verdict was judged against trunk") { - t.Fatalf("the gate's own list does not show the refusal at once: %s", raw) - } - w.later(5 * time.Minute) - if got := linesFor(w, id); len(got) != 0 { - t.Fatalf("said as stalled at five minutes, its check unanswered: %+v", got) - } - // As on the live mesh (events_test.go): the trunk moved, the head is announced again, its fresh verdict passes - // against the new trunk, and the merge is asked and refused again, the trunk having moved once more. - w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2")) - w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - w.later(time.Second) - hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2")) - w.settleAll() - if len(w.forge.merges) != 2 { - t.Fatalf("the fresh verdict did not ask the merge again: %v", w.forge.merges) - } - if got := linesFor(w, id); len(got) != 1 { - t.Fatalf("its check answered and the merge refused again, yet no stalled line: %+v", got) - } -} - // ADR 0505 §4: with no answer, the refusal is said at twice the repository's last check time, at least ten minutes. func TestATrunkMovedRefusalIsStalledAtTwiceTheLastCheckTimeAtLeastTheFloor(t *testing.T) { w, id := refusedForAMovedTrunk(t) @@ -180,9 +147,15 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) { w.settleAll() id := IDOf("novox/app", readyHead) hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) - if _, err := w.h.Recheck(id, "a second look", "tester"); err != nil { + // Judged again (the table's recheck, which the checker's verdict on a new trunk makes): proposed once more. + w.h.mu.Lock() + d := w.h.deliveries[id] + if _, err := Apply(d, EvRecheck, true, Facts{Now: w.h.Now(), Why: "a second look", By: "tester"}); err != nil { + w.h.mu.Unlock() t.Fatal(err) } + d.Check = nil + w.h.mu.Unlock() hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1")) approve(w) w.h.React() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index 39bd8424..65bfdec6 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -380,19 +380,18 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { // A group is not asked while a member's required status is not a success at its head (issue 1275): the group // says which, and is asked once every member may merge. var holdMember, holdSays string - var holdOwn bool var readErr error for _, m := range reads { if gid == "" { break } - says, ownOnly, err := h.notASuccess(m) + says, err := h.notASuccess(m) if err != nil { readErr = err break } if says != "" { - holdMember, holdSays, holdOwn = m.id, says, ownOnly + holdMember, holdSays = m.id, says break } } @@ -422,18 +421,7 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { if readErr != nil || holdSays != "" { stale() h.mu.Lock() - var own func() - // A member's own repository check pending or missing (a sibling's new head put it out of date, issue 1792) is - // asked again here, once for the head: nothing else asks it, and the composed check sets no repository check. - if d := h.deliveries[holdMember]; holdOwn && d != nil && d.Ready != nil { - own = h.askOwnAfterRefusal(d) - } - defer func() { - h.mu.Unlock() - if own != nil { - own() - } - }() + defer h.mu.Unlock() undo() if g := h.groups[gid]; g != nil && holdSays != "" { if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Says != holdSays || g.Hold.Member != holdMember { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go index 77bc463a..cdeebbcb 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/bus_test.go @@ -339,21 +339,18 @@ func (r *runtime) answer(m rpcMessage) { case "seat:" + ControllerSeat + ".delivery-plan": return toolAnswer(map[string]any{"plan": DeliveryPlan{Repository: "novox/app", Moved: []string{"app"}, Summary: "builds app, sent to the node"}}), nil - case "seat:" + ControllerSeat + ".delivery-check": - return toolAnswer(map[string]any{"asked": "check-1"}), nil case "gitea.gitea_open_pulls": return toolAnswer(map[string]any{"pulls": pulls}), nil case "gitea.gitea_commit_statuses": - // Green: both merge checks a success at the head asked, as the gitea module answers. + // Green: mesh/check a success at the head asked, as the gitea module answers. sha, _ := a.Body["sha"].(string) if sha == "" { sha = "0000000" } return toolAnswer(map[string]any{"commit": sha, "statuses": []any{ - map[string]any{"context": "mesh/merge-gate", "state": "success"}, - map[string]any{"context": "mesh/repo-check", "state": "success"}}, - "merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/merge-gate", "mesh/repo-check"}, - "blocking": []any{}, "mergeable": true, "says": "main requires mesh/merge-gate, mesh/repo-check"}}), nil + map[string]any{"context": "mesh/check", "state": "success"}}, + "merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/check"}, + "blocking": []any{}, "mergeable": true, "says": "main requires mesh/check"}}), nil case "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", "gitea.gitea_merge_pull_request", "gitea.gitea_add_comment": return toolAnswer(map[string]any{}), nil @@ -513,11 +510,16 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB // A pull request into main is announced, and the controller's verdict on its head passes. const head = "1111111111111111111111111111111111111111" announced := pr("novox/app", 7, head, "fix/x", "src/a.go") - announced.BaseSHA = "t1t1t1t1t1t1" + announced.BaseSHA = "a1a1a1a1a1a1" announced.Title = "Fix the apostrophe (issue 975)" announced.Body = "The description, with no review line yet." b.publish(t, "mesh.mod.gitea.event.pull.updated", announced) - passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1")) + rt.until("the pull request's key to hold its head", eventWithin, func() bool { + e, err := b.kvs["pulls"].Get(ctx, "novox.app.7") + var k PullKey + return err == nil && json.Unmarshal(e.Value(), &k) == nil && k.Head == head && k.Trunk == "a1a1a1a1a1a1" + }) + b.checkPasses(t, rt, "fix/x", "a1a1a1a1a1a1", judgedPull{"novox/app", 7, head}) // No review names the head, so nothing is asked (ADR 0496, issue 1803): the delivery waits for a review. rt.until("the delivery to wait for a review", eventWithin, func() bool { @@ -535,7 +537,6 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB edit := time.Now() b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7, "title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head}) - _ = passed var key string rt.until("the ask to be written", eventWithin, func() bool { key = onlyAsk(b); return key != "" }) ask := askAt(t, b, key) @@ -550,7 +551,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB rt.until("the pull request's key to hold head, trunk and verdict", eventWithin, func() bool { e, err := b.kvs["pulls"].Get(ctx, "novox.app.7") var k PullKey - return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"} + return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "a1a1a1a1a1a1", Verdict: "pass"} }) // What the transitions owe the forge is written as owed keys; the gate calls no tool of the forge that writes (issue 1349). @@ -698,34 +699,16 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2" for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"), pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} { - p.BaseSHA = "t1t1t1t1t1t1" + p.BaseSHA = "a1a1a1a1a1a1" p.Body = "Reviewed-by: lead@shanks at " + p.HeadSHA b.publish(t, "mesh.mod.gitea.event.pull.updated", p) } - b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1")) - b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t1t1t1t1t1t1")) - - // Both members green: the composed check is asked, once, for both. - // Each member's own check is asked at once (issue 1115), before the group's: wait for the one carrying the group. - rt.until("the composed check to be asked", 10*time.Second, func() bool { - for _, c := range rt.asksOf("seat:" + ControllerSeat + ".delivery-check") { - if c.Body["group"] == "feat/x" { - return true - } - } - return false - }) + // Both members pass at the checker, whose one verdict covers both heads: the group is asked once, no composed check is asked + // of the controller (issue 1318). + b.checkPasses(t, rt, "feat/x", "a1a1a1a1a1a1", judgedPull{"novox/mesh-controller", 1, ctlHead}, judgedPull{"novox/mesh-catalog", 2, catHead}) if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 { t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n) } - if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 0 { - t.Fatalf("the operator was asked before the group's composed check passed: %v", keys) - } - // The composed check passes at both heads: the operator is asked once, for the group. - b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", map[string]any{"group": "feat/x", "id": "check-1", "verdict": "pass", - "summary": "both compose", "members": []map[string]any{ - {"owner": "novox", "repo": "mesh-controller", "commit": ctlHead, "trunk": "t1t1t1t1t1t1"}, - {"owner": "novox", "repo": "mesh-catalog", "commit": catHead, "trunk": "t1t1t1t1t1t1"}}}) var key string rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" }) time.Sleep(500 * time.Millisecond) @@ -796,7 +779,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA rt.untilHolding() const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444" announced := pr("novox/app", 9, head, "fix/y", "src/a.go") - announced.BaseSHA = "t1t1t1t1t1t1" + announced.BaseSHA = "a1a1a1a1a1a1" deliveryOf := func() Delivery { e, err := b.kvs["deliveries"].Get(context.Background(), kvKey(IDOf("novox/app", head))) var d Delivery @@ -809,7 +792,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA // may hand over a later one first, which the table would rightly judge on its own (merged before checked). b.publish(t, "mesh.mod.gitea.event.pull.updated", announced) rt.until("the pull request to be taken", 10*time.Second, func() bool { return deliveryOf().State != "" }) - b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 9, head, "pass", "t1t1t1t1t1t1")) + b.checkPasses(t, rt, "fix/y", "a1a1a1a1a1a1", judgedPull{"novox/app", 9, head}) rt.until("the verdict to be taken", 10*time.Second, func() bool { d := deliveryOf(); return d.State != "" && d.State != Proposed }) b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, head, merge)) rt.until("the merge to be taken", 10*time.Second, func() bool { return deliveryOf().MergedAs != "" }) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkask_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkask_test.go deleted file mode 100644 index 506035c8..00000000 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkask_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" -) - -// A delivery that waits for its check gets one (novox/hq issue 290): a head adopted while it already waited — -// its announcement heard again from the bus's history, which the controller took long ago — has its check asked -// by this owner; so does one whose announcement the controller missed. Once asked, the proposed bound means -// "asked and not answered", and healer H2 may ask it once more before it is the operator's. - -// The live case: a pull request opened before merge checks existed, announced again at this owner's first -// start, read back proposed after a restart. Its check is asked at the first tick, once, for that head alone. -func TestAProposedDeliveryAdoptedWithNoVerdictHasItsCheckAsked(t *testing.T) { - w := newWorld(t) - id := IDOf("novox/hq", head) - w.h.PullUpdated(pr("novox/hq", 94, head, "issues/245-246", "04-ISSUES/245/00-report.md")) - w.settleAll() - if len(w.ctl.checks) != 1 { - t.Fatalf("a head just announced was not asked at once: %v", w.ctl.checks) - } - - // A restart, a minute later: read back proposed with its ask kept; it is not asked again. - again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute)) - if s := again.state(id); s != Proposed { - t.Fatalf("read back, it is %s", s) - } - again.settleAll() - if len(w.ctl.checks) != 1 || w.ctl.checks[0] != ":"+id { - t.Fatalf("its check was asked %v, wanted once for its head alone", w.ctl.checks) - } - d := again.delivery(id) - if d.CheckAsk == nil || d.CheckAsk.ID != "check-1" || d.CheckAsk.By != byOwner || d.State != Proposed { - t.Fatalf("the ask is not kept: %+v (%s)", d.CheckAsk, d.State) - } - if notes := w.forge.notesOn(head); len(notes) == 0 || !strings.Contains(notes[len(notes)-1], "its check asked") { - t.Fatalf("the ask is not noted on the head: %q", notes) - } - // Asked, it is not stalled: the bound runs from the ask. - if st := again.h.Stalled(); len(st) != 0 { - t.Fatalf("a check just asked is stalled: %+v", st) - } - // Asked once: the ticks after do not ask again. - again.later(30 * time.Minute) - again.settleAll() - if len(w.ctl.checks) != 1 { - t.Fatalf("a check asked and in flight was asked again: %v", w.ctl.checks) - } - // The verdict arrives: checked, then ready, by itself. - again.h.Checked(verdict("novox/hq", 94, head, "pass")) - if s := again.state(id); s != Ready { - t.Fatalf("its verdict left it %s", s) - } -} - -// The same head adopted at a first start with no restart: its check is asked at once, with no grace (issue 1115). -func TestAMissedAnnouncementHasItsCheckAskedAtOnce(t *testing.T) { - w := newWorld(t) - id := IDOf("novox/app", head) - w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) - // The controller does not answer: nothing is kept as asked, and it is tried again a minute on. - w.ctl.down = true - w.settleAll() - if d := w.delivery(id); d.CheckAsk != nil { - t.Fatalf("an ask the controller did not take is kept as asked: %+v", d.CheckAsk) - } - w.ctl.down = false - w.settleAll() - if len(w.ctl.checks) != 0 { - t.Fatalf("asked again within the minute: %v", w.ctl.checks) - } - w.later(time.Minute) - w.settleAll() - if len(w.ctl.checks) != 1 || w.delivery(id).CheckAsk == nil { - t.Fatalf("a missed announcement was not asked: %v", w.ctl.checks) - } - // A verdict that arrived in the meantime, or a delivery past its proposal, is never asked. - other := IDOf("novox/app", "bbbbbbbbbbbb2222") - w.h.PullUpdated(pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y")) - w.h.Checked(verdict("novox/app", 8, "bbbbbbbbbbbb2222", "fail")) - w.later(time.Hour) - w.settleAll() - if w.state(other) != Rejected || len(w.ctl.checks) != 1 { - t.Fatalf("a head with a verdict was asked: %s %v", w.state(other), w.ctl.checks) - } -} - -// An announcement heard again carrying its head's decided verdict takes it; a pending one does not. -func TestAnAnnouncementCarryingItsHeadsVerdictTakesIt(t *testing.T) { - w := newWorld(t) - p := pr("novox/app", 7, head, "feat/x", "src/a.go") - p.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"} - w.h.PullUpdated(p) - if s := w.state(IDOf("novox/app", head)); s != Ready { - t.Fatalf("a head whose gate passed on the forge is %s", s) - } - q := pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y") - q.HeadChecks = map[string]string{"mesh/merge-gate": "pending"} - w.h.PullUpdated(q) - if s := w.state(IDOf("novox/app", "bbbbbbbbbbbb2222")); s != Proposed { - t.Fatalf("a pending gate on the forge left it %s", s) - } -} - -// Asked and not answered past the bound: stalled, and H2's close re-asks once — the second close is refused -// by the table, and the delivery is the operator's. -func TestH2ReasksAStalledCheckOnceThenItIsTheOperators(t *testing.T) { - w := newWorld(t) - id := IDOf("novox/app", head) - w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) - w.settleAll() - if len(w.ctl.checks) != 1 { - t.Fatalf("asked %v", w.ctl.checks) - } - if _, err := w.h.Close(id, "too early"); err == nil { - t.Fatal("re-asked within the bound") - } - w.later(Bounds[Proposed].For + time.Minute) - st := w.h.Stalled() - if len(st) != 1 || st[0].ID != id || !strings.Contains(st[0].H2, "re-ask") { - t.Fatalf("stalled %+v", st) - } - // The controller does not take the re-ask: nothing is spent. - w.ctl.down = true - if _, err := w.h.Close(id, "delivery stalled"); err == nil { - t.Fatal("a re-ask the controller did not take was said done") - } - if d := w.delivery(id); d.CheckAsk.Reasked != 0 { - t.Fatal("a re-ask that was not made was spent") - } - w.ctl.down = false - said, err := w.h.Close(id, "delivery stalled") - if err != nil || !strings.Contains(said, "re-asked") { - t.Fatalf("H2's close: %q %v", said, err) - } - d := w.delivery(id) - if len(w.ctl.checks) != 2 || d.State != Proposed || d.CheckAsk.Reasked != 1 || d.CheckAsk.By != byH2 || - d.Transitions[len(d.Transitions)-1].Event != EvReask { - t.Fatalf("after the re-ask: %v %s %+v", w.ctl.checks, d.State, d.CheckAsk) - } - if len(w.h.Stalled()) != 0 { - t.Fatal("a check just re-asked is stalled") - } - // Still not answered: stalled again, and the operator's. - w.later(Bounds[Proposed].For + time.Minute) - st = w.h.Stalled() - if len(st) != 1 || !strings.Contains(st[0].H2, "operator's") { - t.Fatalf("stalled after the re-ask %+v", st) - } - if _, err := w.h.Close(id, "delivery stalled"); err == nil || !strings.Contains(err.Error(), "once already") { - t.Fatalf("a second re-ask: %v", err) - } - if len(w.ctl.checks) != 2 { - t.Fatalf("a second re-ask reached the controller: %v", w.ctl.checks) - } - // The verdict, late, still settles it. - w.h.Checked(verdict("novox/app", 7, head, "pass")) - if s := w.state(id); s != Ready { - t.Fatalf("a late verdict left it %s", s) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checked_shim_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checked_shim_test.go new file mode 100644 index 00000000..2f7fce82 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checked_shim_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "strings" + "time" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// ctxGate and ctxRepo are the two statuses mesh/check replaced: only a base that a revert gives them again requires them +// (unchecked.go mergeChecks), so only tests of that name them. +const ( + ctxGate = "mesh/merge-gate" + ctxRepo = "mesh/repo-check" +) + +// CheckedEvent is a verdict as these tests state it. The gate follows no verdict of the controller any more (issue 1318, +// ADR 0524 §5): the shim below says it the way the mesh says it now, as the head's mesh/check and the checker's verdict at +// the heads, so the table, the Approve and the merges are exercised on the one path they have. +type CheckedEvent struct { + Owner string + Repo string + Number int + Commit string + Verdict string + Summary string + ID string + On string + Report string + Gate *struct { + Verdict string `json:"verdict"` + Summary string `json:"summary"` + Modules []string `json:"modules,omitempty"` + Dependents []string `json:"dependents,omitempty"` + } + RepoCheck *struct { + Verdict string `json:"verdict"` + Summary string `json:"summary"` + } + Plan *DeliveryPlan + Group string + Members []struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Commit string `json:"commit"` + Trunk string `json:"trunk,omitempty"` + } + Trunk string + Beside map[string]string + Took string +} + +// Checked takes a verdict as the checker's: the head's mesh/check, and the checker's verdict at the heads its Approve covers. +func (h *Holder) Checked(c CheckedEvent) { + h.mu.Lock() + defer h.mu.Unlock() + state := map[string]string{"pass": "success", "warning": "warning", "fail": "failure", "error": "error"}[c.Verdict] + if state == "" { + state = "error" + } + if c.Group != "" { + g := h.groups[c.Group] + if g == nil { + return + } + named := map[string]string{} + for _, m := range c.Members { + if m.Trunk != "" { + named[strings.ToLower(m.Owner+"/"+m.Repo)] = m.Trunk + } + } + h.putChecker(h.membersOf(g), c.Verdict, named) + return + } + repository := strings.ToLower(c.Owner + "/" + c.Repo) + id := IDOf(repository, c.Commit) + if h.deliveries[id] == nil && c.Number != 0 { + d := &Delivery{ID: id, Repository: repository, Commit: c.Commit, Number: c.Number, Created: h.Now()} + if t, err := Apply(d, EvAnnounced, false, Facts{Now: h.Now(), Why: "its verdict arrived before its head was heard"}); err == nil { + h.moved(d, []Transition{t}) + } + } + d := h.deliveries[id] + if d == nil { + return + } + if c.Plan != nil { + d.Plan = c.Plan + } + words := map[string]string{"success": "pass", "warning": "pass", "failure": "failed: " + c.Summary, "error": "error"}[state] + line := words + " " + shortOf(c.Commit, 12) + if c.Trunk != "" { + line += " on " + shortOf(c.Trunk, 12) // a verdict that names no trunk commit is judged against none + } + h.takeCheckStatus(repository, c.Commit, state, line+" | "+c.Summary) + if d := h.deliveries[id]; d != nil && state == "success" && d.Ready != nil && d.Ready.Merged == nil { + h.mergeAgainNow(d) // as the forge's success status event does (CommitStatus) + } + if d := h.deliveries[id]; d != nil && d.Check != nil && c.Beside != nil { + d.Check.Beside, d.Check.Took = c.Beside, c.Took + } + if d := h.deliveries[id]; d != nil { + h.putChecker(h.askMembers(d), c.Verdict, nil) + } +} + +// putChecker keeps the checker's verdict at these members' heads, judged on each repository's trunk the members name or, for +// none named, the one the check line named. +func (h *Holder) putChecker(members []*Delivery, verdict string, named map[string]string) { + state := "pass" + switch verdict { + case "pass", "warning": + default: + state = "fail" + } + hash, err := sdk.HeadsHash(headsOf(members)) + if err != nil { + return + } + trunks := map[string]string{} + for _, d := range members { + if d.Check != nil && d.Check.Trunk != "" { + trunks[strings.ToLower(d.Repository)] = d.Check.Trunk + } + if d.TrunkNow != "" && trunks[strings.ToLower(d.Repository)] == "" { + trunks[strings.ToLower(d.Repository)] = d.TrunkNow + } + } + for repository, trunk := range named { + trunks[repository] = trunk + } + value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "trunk": trunks, "state": state}) + if h.checker == nil { + h.checker = map[string]checkerVerdict{} + } + var v checkerVerdict + _ = json.Unmarshal(value, &v) + h.checker[hash] = v + h.Poke() +} + +var _ = time.Second diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkerbus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkerbus_test.go new file mode 100644 index 00000000..24b74823 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checkerbus_test.go @@ -0,0 +1,67 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "testing" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// A pull request and the head the checker judged, for checkPasses. +type judgedPull struct { + repository string + number int + head string +} + +// checkPasses says on the bus what the mesh says of heads the checker passed (issue 1302, ADR 0447): its verdict at these +// heads in its verdicts bucket, each pull request's base requiring mesh/check and the forge's word that its bridge set it, the +// line naming the head and the trunk commit it judged. The gate follows no verdict of the controller (issue 1318). +func (b *testBus) checkPasses(t *testing.T, rt *runtime, group, trunk string, pulls ...judgedPull) { + t.Helper() + var heads []sdk.Head + trunks := map[string]string{} + for _, p := range pulls { + heads = append(heads, sdk.Head{Repository: p.repository, Number: p.number, Head: p.head}) + trunks[p.repository] = trunk + } + hash, err := sdk.HeadsHash(heads) + if err != nil { + t.Fatal(err) + } + verdict, _ := json.Marshal(map[string]any{"v": 1, "group": group, "heads": hash, "trunk": trunks, "state": "pass"}) + if _, err := b.kvs[checkerVerdicts].Put(context.Background(), group+"."+hash, verdict); err != nil { + t.Fatal(err) + } + for _, p := range pulls { + owner, repo := splitRepository(p.repository) + line := "pass " + p.head[:12] + " on " + trunk[:12] + " | unit 3 run, 0 kept" + rt.mu.Lock() + found := false + for i := range rt.openPulls { + o := &rt.openPulls[i] + if o.Owner == owner && o.Repo == repo && o.Number == p.number { + o.Required, o.Statuses, o.Checks, found = []string{ctxCheck}, []string{ctxCheck}, map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}, true + } + } + if !found { + rt.openPulls = append(rt.openPulls, OpenPull{Owner: owner, Repo: repo, Number: p.number, Base: "main", HeadSHA: p.head, + Required: []string{ctxCheck}, Statuses: []string{ctxCheck}, Checks: map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}}) + } + rt.mu.Unlock() + b.publish(t, "mesh.mod.gitea.event.forge.status", map[string]any{"owner": owner, "repo": repo, "sha": p.head, + "context": ctxCheck, "state": "success", "description": line}) + } +} + +func splitRepository(r string) (owner, repo string) { + for i := range r { + if r[i] == '/' { + return r[:i], r[i+1:] + } + } + return "", r +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go index b0c33f30..3ee5c727 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks.go @@ -18,9 +18,8 @@ import ( // Context names of the statuses this owner can say more of than the forge keeps. const ( - // ctxGate is the controller's merge gate, kept where a base still requires it; mesh/check is ctxCheck (onestatus.go). - ctxGate = "mesh/merge-gate" - ctxRepo = "mesh/repo-check" + // mesh/check, the one status main requires, is ctxCheck (onestatus.go); the controller's mesh/merge-gate and hq's + // mesh/repo-check are gone with the merge gate (ADR 0524 §5, ADR 0527 §2). ctxDelivery = "mesh/delivery" ctxGroup = "mesh/delivery-group" ) @@ -136,10 +135,7 @@ func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAn } else { // The forge away: the statuses the verdict set, as the forge holder sets them from it, said as such. if v := a.Verdict; v != nil { - a.Statuses = append(a.Statuses, CheckStatus{Name: ctxGate, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"}) - if v.Repo != "" { - a.Statuses = append(a.Statuses, CheckStatus{Name: ctxRepo, State: forgeStateOf(v.Repo), Source: "mesh-delivery's verdict; the forge could not be read"}) - } + a.Statuses = append(a.Statuses, CheckStatus{Name: ctxCheck, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"}) } a.Merge = ForgeMerge{Branch: firstOf(base, "main"), Says: "not known: the forge could not be read, and only it holds the protection"} } @@ -211,29 +207,12 @@ func newerHead(d, e *Delivery) bool { // addWhatIsKnown joins this owner's verdict to the status it set, and the delivery to its own statuses. func (h *Holder) addWhatIsKnown(s *CheckStatus, v *Verdict, d *checksDelivery) { switch s.Name { - case ctxGate, ctxRepo: - if v == nil { - return - } - s.Build, s.CheckedOn = v.ID, v.On - if !v.At.IsZero() { - s.CheckedAt = v.At.UTC().Format(time.RFC3339) - } - if s.Name == ctxGate { - s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary - if len(v.Modules) > 0 { - s.Verdict += " (modules: " + strings.Join(v.Modules, ", ") - if len(v.Dependents) > 0 { - s.Verdict += "; built after them: " + strings.Join(v.Dependents, ", ") - } - s.Verdict += ")" - } - } else if v.Repo != "" { - s.Verdict = strings.ToUpper(v.Repo) + " — " + v.RepoSaid - } case ctxCheck: if v != nil && v.Status == ctxCheck { s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary + if !v.At.IsZero() { + s.CheckedAt = v.At.UTC().Format(time.RFC3339) + } } case ctxDelivery: if d != nil { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks_test.go index b2565ef2..818c53f4 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/checks_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "strings" "testing" ) @@ -10,26 +9,17 @@ import ( // the forge's statuses, the verdict whole as the controller said it, and whether the base's protection lets // it merge. -// checkedAs is the controller's `checked` as it comes off the bus, both layers said. -func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid, repoVerdict, repoSaid string) CheckedEvent { +// checkedAs is a verdict as the checker says it on the head's mesh/check. +func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid string) CheckedEvent { t.Helper() owner, name, _ := strings.Cut(repo, "/") - raw := map[string]any{"owner": owner, "repo": name, "number": number, "commit": sha, "verdict": gate, - "summary": gateSaid, "id": "build-" + sha[:8], "on": "the-build-node", "report": "== compose\n" + gateSaid, - "gate": map[string]any{"verdict": gate, "summary": gateSaid, "modules": []string{"app"}, "dependents": []string{"web"}}, - "repo-check": map[string]any{"verdict": repoVerdict, "summary": repoSaid}} - var c CheckedEvent - if err := json.Unmarshal(mustJSON(raw), &c); err != nil { - t.Fatal(err) - } - return c + return CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: gateSaid, ID: "build-" + sha[:8]} } -// statusesAsTheForgeSets puts the two statuses the forge's holder sets from a verdict: clipped, as it keeps them. +// statusesAsTheForgeSets puts the status the forge's bridge sets from a verdict: clipped, as the forge keeps it. func statusesAsTheForgeSets(w *world, repo, sha string, c CheckedEvent) { owner, name, _ := strings.Cut(repo, "/") - _ = w.forge.Status(owner, name, sha, ctxGate, forgeStateOf(c.Gate.Verdict), clip(c.Gate.Verdict+": "+c.Gate.Summary, 40), "https://forge.invalid/pr") - _ = w.forge.Status(owner, name, sha, ctxRepo, forgeStateOf(c.RepoCheck.Verdict), clip(c.RepoCheck.Verdict+": "+c.RepoCheck.Summary, 40), "https://forge.invalid/pr") + _ = w.forge.Status(owner, name, sha, ctxCheck, forgeStateOf(c.Verdict), clip(c.Verdict+": "+c.Summary, 40), "https://forge.invalid/pr") } func statusNamed(a *ChecksAnswer, name string) *CheckStatus { @@ -43,11 +33,11 @@ func statusNamed(a *ChecksAnswer, name string) *CheckStatus { func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *testing.T) { w := newWorld(t) - w.forge.required = []string{ctxGate, ctxRepo} + w.forge.required = []string{ctxCheck} w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Title: "a change", State: "open", Base: "main", Head: "feat/x", HeadSHA: head} w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) said := "0 of 4 compose: anchor refuses app's manifest — a provision nothing provides, and three more machines alike" - c := checkedAs(t, "novox/app", 7, head, "fail", said, "pass", "its merge-check.sh passed") + c := checkedAs(t, "novox/app", 7, head, "fail", said) w.h.Checked(c) w.settleAll() statusesAsTheForgeSets(w, "novox/app", head, c) @@ -59,35 +49,30 @@ func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t * if a.Commit != head || a.Pull == nil || a.Pull.Number != 7 || a.Forge != "" { t.Fatalf("the head read is %q, pull %+v, forge %q", a.Commit, a.Pull, a.Forge) } - gate := statusNamed(a, ctxGate) + gate := statusNamed(a, ctxCheck) if gate == nil || gate.State != "failure" || gate.Source != "the forge" || gate.SetBy != "mesh-admin" { - t.Fatalf("the gate's status is %+v", gate) + t.Fatalf("the check's status is %+v", gate) } - // The forge clipped it; the verdict says it whole, with the machine, the build and the modules. + // The forge clipped it; the verdict says it whole. if strings.Contains(gate.Description, "three more machines") { t.Fatalf("the fake forge did not clip: %q", gate.Description) } - if !strings.Contains(gate.Verdict, "FAIL — 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") || - !strings.Contains(gate.Verdict, "modules: app; built after them: web") { - t.Fatalf("the gate's verdict is %q", gate.Verdict) + if !strings.Contains(gate.Verdict, "FAIL — failed: 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") { + t.Fatalf("the check's verdict is %q", gate.Verdict) } - if gate.Build != "build-"+head[:8] || gate.CheckedOn != "the-build-node" || gate.CheckedAt == "" { - t.Fatalf("the gate was checked as %q on %q at %q", gate.Build, gate.CheckedOn, gate.CheckedAt) - } - repo := statusNamed(a, ctxRepo) - if repo == nil || repo.State != "success" || repo.Verdict != "PASS — its merge-check.sh passed" || repo.CheckedOn != "the-build-node" { - t.Fatalf("the repository check's status is %+v", repo) + if gate.CheckedAt == "" { + t.Fatal("the check says no time it was heard") } if d := statusNamed(a, ctxDelivery); d == nil || !strings.HasPrefix(d.Verdict, "rejected: waits for a new head") { t.Fatalf("the delivery's status is %+v", d) } - if a.Verdict == nil || !strings.Contains(a.Verdict.Report, "== compose") { + if a.Verdict == nil || !strings.Contains(a.Verdict.Summary, "three more machines") { t.Fatalf("the verdict whole is %+v", a.Verdict) } if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) || a.Delivery.State != Rejected { t.Fatalf("the delivery is %+v", a.Delivery) } - if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxGate { + if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxCheck { t.Fatalf("the merge is %+v", a.Merge) } if !strings.Contains(a.Says, "novox/app#7 at aaaaaaaa") || !strings.Contains(a.Says, "NOT mergeable") { @@ -101,10 +86,10 @@ func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t * func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) { w := newWorld(t) - w.forge.required = []string{ctxGate, ctxRepo} + w.forge.required = []string{ctxCheck} newer := "bbbbbbbbbbbb2222" w.h.PullUpdated(pr("novox/app", 7, head, "feat/x")) - w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose", "pass", "passed")) + w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose")) w.h.PullUpdated(pr("novox/app", 7, newer, "feat/x")) w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, State: "open", Base: "main", HeadSHA: newer} @@ -118,27 +103,27 @@ func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) { if a.Verdict != nil { t.Fatalf("the older head's verdict was given for the newer: %+v", a.Verdict) } - if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 2 || a.Merge.Blocking[0].State != "missing" { + if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].State != "missing" { t.Fatalf("a head not checked yet merges as %+v", a.Merge) } // Passed, it merges. - c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes", "pass", "its merge-check.sh passed") + c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes") w.h.Checked(c) statusesAsTheForgeSets(w, "novox/app", newer, c) a, _ = w.h.Checks("novox/app", 7, "") if a.Merge.Mergeable == nil || !*a.Merge.Mergeable || !strings.Contains(a.Says, "mergeable") || strings.Contains(a.Says, "NOT") { t.Fatalf("a head that passed: %+v, says %q", a.Merge, a.Says) } - if g := statusNamed(a, ctxGate); g == nil || g.Verdict != "PASS — every machine composes (modules: app; built after them: web)" { - t.Fatalf("its gate: %+v", g) + if g := statusNamed(a, ctxCheck); g == nil || !strings.HasPrefix(g.Verdict, "PASS — pass ") || !strings.Contains(g.Verdict, "every machine composes") { + t.Fatalf("its check: %+v", g) } } func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) { w := newWorld(t) w.h.PullUpdated(pr("novox/app", 7, head, "feat/x")) - c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes", "pass", "passed") + c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes") w.h.Checked(c) statusesAsTheForgeSets(w, "novox/app", head, c) @@ -175,7 +160,7 @@ func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) { func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing.T) { w := newWorld(t) w.h.PullUpdated(pr("novox/app", 7, head, "feat/x")) - w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note", "error", "the toolchain was not there")) + w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note")) w.forge.down = true a, err := w.h.Checks("novox/app", 7, "") @@ -185,10 +170,9 @@ func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing if a.Forge == "" || a.Commit != head || a.Verdict == nil { t.Fatalf("with the forge away: forge %q, commit %q, verdict %+v", a.Forge, a.Commit, a.Verdict) } - gate, repo := statusNamed(a, ctxGate), statusNamed(a, ctxRepo) - if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") || - repo == nil || repo.State != "error" || repo.Verdict != "ERROR — the toolchain was not there" { - t.Fatalf("the statuses from the verdict: %+v %+v", gate, repo) + gate := statusNamed(a, ctxCheck) + if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") { + t.Fatalf("the status from the verdict: %+v", gate) } if a.Merge.Mergeable != nil || !strings.Contains(a.Says, "mergeable: not known") { t.Fatalf("whether it merges, with the forge away: %+v, %q", a.Merge, a.Says) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go index 51a03475..8b457a63 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/configheld.go @@ -95,59 +95,8 @@ func (h *Holder) askAgainForConfig(id, why string) { } } -// askProposedAgain asks a proposed delivery's check again, its merge check put back to pending first. +// askProposedAgain says why a proposed delivery's check is not asked again for a config value: the checker's (issue 1302), and +// whether a held config value is weighed is its composition's. Nothing is asked of the controller (issue 1318). func (h *Holder) askProposedAgain(id, why string) { - h.mu.Lock() - d := h.deliveries[id] - if d == nil || d.State != Proposed { - h.mu.Unlock() - return - } - if d.judgedByCheck() { - // The checker's (issue 1302): its check is not asked here, and whether a held config value is weighed is the - // checker's composition's. - h.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id)) - h.mu.Unlock() - return - } - owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number - h.mu.Unlock() - if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil { - h.Logf("[mesh-delivery] %s: its check is not asked again for a config value: %v", id, err) - return - } - h.mu.Lock() - d = h.deliveries[id] - if d == nil || d.State != Proposed { - h.mu.Unlock() - return - } - d.Check = nil - if g := h.groups[d.Group]; g != nil && g.Check != nil { - now := h.Now() - g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why) - g.Check.OutOfDateAt = now - g.Updated = now - h.keepGroup(g) - } - m := MemberOf(d) - h.mu.Unlock() - asked, err := h.Controller.Check("", []Member{m}) - h.mu.Lock() - defer h.mu.Unlock() - if err != nil { - h.Logf("[mesh-delivery] %s's check cannot be asked again yet (%v); it is asked at the next look", id, err) - h.again("a check asked again for a config value that could not be asked") - return - } - d = h.deliveries[id] - if d == nil || d.State != Proposed { - return - } - d.CheckAsk = &CheckAsk{ID: asked, At: h.Now(), By: byOwner, Why: why} - h.Logf("[mesh-delivery] %s: its check asked again as %s — %s", id, asked, why) - d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Since: h.Now(), - Line: oneLine(fmt.Sprintf("%s delivery %s: its check asked again by %s as %s: %s", h.Now().UTC().Format("2006-01-02T15:04:05Z07:00"), - id, byOwner, asked, why))}) - h.keep(d) + h.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id)) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/covered_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/covered_test.go index a7caf67d..dd496f13 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/covered_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/covered_test.go @@ -392,100 +392,6 @@ func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) { } } -// **A group member is not held for a composed check that has not answered** (issue 316): it waits published, -// and goes on once the check passes for the heads that merged. -func TestAGroupMemberWaitsForItsComposedCheckToAnswer(t *testing.T) { - w := newWorld(t) - const a, b = "novox/a", "novox/b" - const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2" - aID := IDOf(a, aHead) - pa, pb := pr(a, 1, aHead, "feat/k", "x"), pr(b, 2, bHead, "feat/k", "y") - w.h.PullUpdated(pa) - w.h.PullUpdated(pb) - w.h.Checked(verdictMoving(a, 1, aHead, "a")) - w.h.Checked(verdictMoving(b, 2, bHead, "b")) - w.settleAll() - if len(w.ctl.checks) != 1 { - t.Fatalf("the composed check was asked %v", w.ctl.checks) - } - w.h.PullMerged(mergedAt(pa, "m1m1m1m1m1m1", t0)) - w.h.WalkMoved(w.ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true))) - w.settleAll() - if s := w.state(aID); s != Published { - t.Fatalf("merged while its group's composed check had not answered, it is %s (%s)", s, w.delivery(aID).HeldWhy) - } - w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: "pass", Summary: "both compose"}) - w.settleAll() - if s := w.state(aID); s != Published || len(w.ctl.delivers) != 0 { - t.Fatalf("its composed check passed, but the other member is not merged: %s, let go %v", s, w.ctl.delivers) - } - // The other member merges: every member is on the trunk, and the walk is let go (ADR 0418 §2). - w.h.PullMerged(mergedAt(pb, "m2m2m2m2m2m2", t0)) - w.h.WalkMoved(w.ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true))) - w.settleAll() - if s := w.state(aID); s != Delivering || !containsString(w.ctl.delivers, "plan-a") { - t.Fatalf("every member merged, and it is %s, let go %v", s, w.ctl.delivers) - } -} - -// **A member already held for its group's composed check goes on when that check passes for the heads that -// merged** (issue 316); a check that failed, or passed for other heads, keeps it held for a person. -func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) { - const a, b = "novox/a", "novox/b" - const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2" - aID, bID := IDOf(a, aHead), IDOf(b, bHead) - for _, c := range []struct { - name string - verdict string - commits map[string]string - want State - }{ - {"passed for the heads that merged", "pass", map[string]string{aID: aHead, bID: bHead}, Delivering}, - {"warned for the heads that merged", "warning", map[string]string{aID: aHead, bID: bHead}, Delivering}, - {"failed", "fail", map[string]string{aID: aHead, bID: bHead}, Held}, - {"passed for other heads", "pass", map[string]string{aID: "a9a9a9a9a9a9a9a9", bID: bHead}, Held}, - } { - t.Run(c.name, func(t *testing.T) { - store := newMemStore() - why := "its group feat/k's composed check did not pass for the heads that merged; a person decides that it goes on" - da := &Delivery{ID: aID, Repository: a, Commit: aHead, Number: 1, Base: "main", Branch: "feat/k", Group: "feat/k", - State: Held, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m1m1m1m1m1m1", MergedAt: t0, HeldWhy: why, - Plan: planMoving(a, "a"), Check: &Verdict{Gate: "pass"}, - Walk: &WalkSeen{ID: "plan-a", Commit: "m1m1m1m1m1m1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}} - db := &Delivery{ID: bID, Repository: b, Commit: bHead, Number: 2, Base: "main", Branch: "feat/k", Group: "feat/k", - State: Published, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m2m2m2m2m2m2", MergedAt: t0, - Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"}, - Walk: &WalkSeen{ID: "plan-b", Commit: "m2m2m2m2m2m2", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}} - g := &Group{ID: "feat/k", Members: []string{aID, bID}, Closed: true, - Check: &GroupCheck{Asked: "check-old", At: t0, Commits: c.commits}} - for _, d := range []*Delivery{da, db} { - _ = store.PutDelivery(d) - } - _ = store.PutGroup(g) - ctl := newFakeController() - ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true)) - ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true)) - w := newWorldOver(t, store, ctl, newFakeForge(), t0.Add(time.Minute)) - w.settleAll() - if s := w.state(aID); s != Held { - t.Fatalf("before its composed verdict it is %s", s) - } - w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-old", Verdict: c.verdict, Summary: "composed: " + c.verdict}) - w.settleAll() - if s := w.state(aID); s != c.want { - t.Fatalf("its composed check %s, and it is %s (%s), want %s", c.name, s, w.delivery(aID).HeldWhy, c.want) - } - if c.want == Delivering && !containsString(ctl.delivers, "plan-a") { - t.Fatalf("lifted, its walk was not let go: %v", ctl.delivers) - } - // A composed check of other heads is asked again for these heads (novox/hq ADR 0323 §6). - if c.name == "passed for other heads" && !containsString(ctl.checks, "feat/k:"+aID+","+bID) { - t.Fatalf("the composed check was not asked again for the heads that merged: %v", ctl.checks) - } - }) - } -} - // **A module a release carried to every machine the held delivery sends it to covers it** (novox/hq issue // 351). A build the controller held back for a person and released on their word reaches each machine by a // backlog walk, which credits the delivery that published the build one step per machine — never the walk's 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 e8f6c0f2..a1286af3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go @@ -47,8 +47,8 @@ func (h *Holder) AnswersChanged() { } } -// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check; hq's mesh/repo-check and, on a -// base that still requires it, mesh/merge-gate) says a +// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check and, on a +// base that still requires it after a revert, the two it replaced) says a // check started, answered, or was put back, so the forge's open pull requests are read to see which heads still wait on // one (SeeOpenPulls). A status of any other context shows nothing the merge needs. // diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go index f5e1f299..31c99f9e 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "strings" "testing" - "time" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -20,7 +19,14 @@ func hear(t *testing.T, w *world, key string, body any) { if err != nil { t.Fatal(err) } - if err := handle(w.h, stdio.Envelope{Key: key, Body: raw}); err != nil { + if key == ControllerSeat+".checked" { + // The gate follows no verdict of the controller any more (issue 1318): a test says it as the checker's. + var c CheckedEvent + if err := json.Unmarshal(raw, &c); err != nil { + t.Fatal(err) + } + w.h.Checked(c) + } else if err := handle(w.h, stdio.Envelope{Key: key, Body: raw}); err != nil { t.Fatal(err) } w.h.React() // the look the event pokes, taken by the holder's worker @@ -85,53 +91,6 @@ func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) { } } -// Issue 1022 (ADR 0421 §3): a passing verdict that arrives out of date is not asked; its check against the trunk's -// head is, and the Approve is asked on the passing verdict. -func TestAStalePassingVerdictAsksTheCheckAgainBeforeTheOperator(t *testing.T) { - w := announced(t) - // Another merge moved the trunk before the verdict, judged against the trunk before, arrived. - w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2")) - hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) - if w.op.count() != 0 { - t.Fatalf("the operator was asked for a verdict out of date") - } - if len(w.ctl.checks) != 1 || w.ctl.baseSHAs[0] != "t2t2t2t2t2t2" { - t.Fatalf("its check against the trunk's head was not asked once: %v %v", w.ctl.checks, w.ctl.baseSHAs) - } - hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2")) - if w.op.count() != 1 { - t.Fatalf("the passing verdict at the trunk's head asked the operator %d times, wanted once", w.op.count()) - } - if len(w.ctl.checks) != 1 { - t.Fatalf("its check was asked again: %v", w.ctl.checks) - } -} - -// Issue 1022 (ADR 0421 §3, §4): an ask raised stays when the trunk moves; the Approve is given; the merge waits for the -// fresh verdict and lands on it; no second Approve is asked. -func TestAnAskRaisedStaysThroughATrunkMoveAndTheMergeLandsOnTheFreshVerdict(t *testing.T) { - w := announced(t) - id := IDOf("novox/app", readyHead) - hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) - w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2")) - w.h.React() - if w.state(id) != Proposed || w.op.count() != 1 || len(w.op.open()) != 1 { - t.Fatalf("after the trunk moved: %s, asks %d, open %v", w.state(id), w.op.count(), w.op.open()) - } - if len(w.ctl.checks) != 1 { - t.Fatalf("its check against the trunk's head was not asked at once: %v", w.ctl.checks) - } - approve(w) - if len(w.forge.merges) != 0 { - t.Fatalf("merged on a verdict out of date: %v", w.forge.merges) - } - hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2")) - if len(w.forge.merges) != 1 || w.delivery(id).Ready.Merged == nil || w.op.count() != 1 { - t.Fatalf("the passing verdict did not ask the merge at once, on the same Approve: %v %+v asks %d", w.forge.merges, - w.delivery(id).Ready, w.op.count()) - } -} - // Issue 1022: an Approve whose merge the tool refused is asked again at once on the verdict that decides it. func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) { w := announced(t) @@ -229,37 +188,3 @@ func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) { t.Fatalf("said more than once: %v", w.forge.comments) } } - -// Issue 1022 (ADR 0421 §7), as novox/hq ADR 0505 §5 changed it: a check that failed after the ask merges nothing, is -// said once on the pull request at once, and is a stalled line only after two hours with no new head. -func TestACheckThatFailsAfterTheAskIsNotMergedAndIsSaid(t *testing.T) { - w := announced(t) - id := IDOf("novox/app", readyHead) - hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) - if _, err := w.h.Recheck(id, "a second look", "tester"); err != nil { - t.Fatal(err) - } - hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1")) - approve(w) - if len(w.forge.merges) != 0 { - t.Fatalf("merged past a failed check: %v", w.forge.merges) - } - w.h.React() - said := 0 - for _, c := range w.forge.comments { - if strings.Contains(c, "not merged") { - said++ - } - } - if said != 1 { - t.Fatalf("the failure is not said once: %v", w.forge.comments) - } - if lines := w.h.Stalled(); len(lines) != 0 { - t.Fatalf("the failure is the operator's before its author had two hours: %+v", lines) - } - w.later(2*time.Hour + time.Minute) - lines := w.h.Stalled() - if len(lines) != 1 || lines[0].ID != id || strings.Contains(lines[0].Says, "marked") { - t.Fatalf("the failure is not a stalled line: %+v", lines) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go index 2e1d3c87..4e813433 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/fakes_test.go @@ -128,19 +128,6 @@ type fakeController struct { planned *DeliveryPlan // forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n". forgot []string - // queue is the build queue as Check fills it: every ask made is held, waiting, until a test takes it out (a lost - // ask, issue 1274); queueDown makes the queue unreadable. - queue []QueuedAsk - queueDown bool -} - -func (c *fakeController) Queue() ([]QueuedAsk, error) { - c.mu.Lock() - defer c.mu.Unlock() - if c.queueDown { - return nil, errors.New("the queue cannot be read") - } - return append([]QueuedAsk(nil), c.queue...), nil } // BackupsAgain records the word, and clears the walk's failed backups as the controller does. @@ -192,32 +179,6 @@ func (c *fakeController) Plan(repository, base, head string, paths, dirs, remove } return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil } -func (c *fakeController) Check(group string, ms []Member) (string, error) { - c.mu.Lock() - defer c.mu.Unlock() - if c.down { - return "", errors.New("down") - } - if c.onCheck != nil { - c.onCheck() - } - c.asked++ - id := fmt.Sprintf("check-%d", c.asked) - if c.emptyCheck { - id = "" - } - var heads []string - for _, m := range ms { - heads = append(heads, m.ID) - } - c.checks = append(c.checks, group+":"+strings.Join(heads, ",")) - for _, m := range ms { - c.queue = append(c.queue, QueuedAsk{ID: id, State: "waiting", Kind: "check", Repository: m.Repository, Commit: m.Head}) - c.checkReady = append(c.checkReady, m.Ready) - c.baseSHAs = append(c.baseSHAs, m.BaseSHA) - } - return id, nil -} func (c *fakeController) Deliver(walk, why string) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go index c676babe..4cfb1b6b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go @@ -29,29 +29,25 @@ func readOf(d *Delivery, head string) memberRead { } // notAsSuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success; -// empty when all are. ownOnly is whether only the member's own repository check is missing or pending. An error is a +// empty when all are. An error is a // read that could not be made: the member then counts as not mergeable. -func (h *Holder) notASuccess(m memberRead) (says string, ownOnly bool, err error) { +func (h *Holder) notASuccess(m memberRead) (says string, err error) { fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base) if err != nil { - return "", false, err + return "", err } if fc.Merge.Mergeable == nil { - return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says) + return "", fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says) } if *fc.Merge.Mergeable { - return "", false, nil + return "", nil } var parts []string - ownOnly = len(fc.Merge.Blocking) > 0 for _, b := range fc.Merge.Blocking { parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State)) - if b.Context != ctxRepo || (b.State != "pending" && b.State != "missing") { - ownOnly = false - } } return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "), - strings.Join(parts, ", ")), ownOnly, nil + strings.Join(parts, ", ")), nil } // unmergedLive are a group's members not yet merged and not over, in the group's order. Under the lock. @@ -76,7 +72,7 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool { } } for _, m := range reads { - says, ownOnly, err := h.notASuccess(m) + says, err := h.notASuccess(m) if err == nil && says == "" { continue } @@ -88,7 +84,6 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool { h.mu.Unlock() return false } - var own func() if g := h.groups[gid]; g != nil { if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != m.id { h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, m.id, shortOf(m.head, 12), says) @@ -103,15 +98,9 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool { g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since} h.keepGroup(g) } - if d := h.deliveries[m.id]; ownOnly && d != nil && d.Ready != nil { - own = h.askOwnAfterRefusal(d) // its own repository check is what is missing: asked, once for the head - } release() h.again("a group's merge held for a required status") h.mu.Unlock() - if own != nil { - own() - } return false } h.mu.Lock() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go index 952c40f9..425b4698 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_test.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "errors" - "sort" "strings" "testing" "time" @@ -48,73 +47,6 @@ func approvedGroup(t *testing.T) *world { return w } -// Issue 1047 (the rule the lead settled, ADR 0421 §3): an approved group whose trunk moved through a merge that is not -// its member's has each out-of-date member's check asked at once, then the composed check, and merges on the same -// Approve — with no second one asked. -func TestAnApprovedGroupWhoseTrunkMovedIsCheckedAgainAndMergedOnTheSameApprove(t *testing.T) { - w := approvedGroup(t) - asked := len(w.ctl.checks) - w.h.PullMerged(merged(gCtl, 5, "5555555555555555", gT1)) - w.settleAll() - // The member whose trunk moved has its own check asked, in the same look; the other member's trunk did not move. - if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+IDOf(gCtl, gHc) { - t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got) - } - if w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1[:12] && w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1 { - t.Fatalf("its check was not asked against the trunk's head: %v", w.ctl.baseSHAs) - } - // The Approve is given while the checks run: nothing merges on the verdict out of date. - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - if len(w.forge.merges) != 0 { - t.Fatalf("merged while the group's checks were out of date: %v", w.forge.merges) - } - // The member is judged again: the composed check is asked on the new verdicts. - w.later(time.Minute) - w.h.Checked(judged(gCtl, 1, gHc, "pass", gT1)) - w.settleAll() - g := w.h.groups["feat/g"] - last := w.ctl.checks[len(w.ctl.checks)-1] - if last != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) { - t.Fatalf("the composed check was not asked again: %v", w.ctl.checks) - } - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"}) - w.settleAll() - sort.Strings(w.forge.merges) - if len(w.forge.merges) != 2 || w.forge.merges[0] != "novox/mesh-catalog#2@"+gHk || w.forge.merges[1] != "novox/mesh-controller#1@"+gHc { - t.Fatalf("both members were not merged on the new composed verdict: %v", w.forge.merges) - } - if w.op.count() != 1 { - t.Fatalf("a second Approve was asked: %d", w.op.count()) - } -} - -// ADR 0418 §4, issue 1275/AC3: a member the forge refuses stops the pass (the members after it are not asked), and is -// merged again on its fresh check, the others after it in the same pass. -func TestAMemberTheForgeRefusesStopsThePassAndIsMergedAgainOnItsFreshCheck(t *testing.T) { - w := approvedGroup(t) - w.forge.refuse = errAskedAgain - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - if len(w.forge.merges) != 1 { - t.Fatalf("the pass stops at the first refusal: %v", w.forge.merges) - } - w.forge.refuse = nil - w.later(2 * time.Minute) - w.settleAll() - if len(w.forge.merges) != 1 { - t.Fatalf("a refused member is asked again with no event, on a timer or at each look: %v", w.forge.merges) - } - w.h.Checked(CheckedEvent{Group: "feat/g", ID: w.h.groups["feat/g"].Check.Asked, Verdict: "pass", Summary: "composed again"}) - w.settleAll() - if len(w.forge.merges) != 3 { - t.Fatalf("a refused member is asked again on the next composed verdict, then the next: %v", w.forge.merges) - } - if d := w.delivery(IDOf(gCat, gHk)); d.Ready.Merged == nil { - t.Fatalf("not merged: %+v", d.Ready) - } -} - // Issue 978 (the pin): a feature's single pull request in one repository is a lone delivery, asked alone. func TestAFeaturesSinglePullRequestIsALoneDeliveryAskedAlone(t *testing.T) { w := newWorld(t) @@ -148,125 +80,6 @@ func TestTheGroupsAnswerSaysItsGateIsTheComposedChecks(t *testing.T) { } } -// ADR 0403 §1: a member whose own gate the group's composed check judges instead (the controller says its gate layer -// "composed") counts as passed here once its repository's check did not fail, so the group's composed check is asked. -func TestAMemberWhoseGateIsTheComposedChecksCountsAsPassed(t *testing.T) { - w := newWorld(t) - w.op.auto = false - w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go")) - w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json")) - for _, c := range []struct { - repo string - n int - head string - }{{gCtl, 1, gHc}, {gCat, 2, gHk}} { - v := judged(c.repo, c.n, c.head, "pass", gTrunk0) - v.Gate = &struct { - Verdict string `json:"verdict"` - Summary string `json:"summary"` - Modules []string `json:"modules,omitempty"` - Dependents []string `json:"dependents,omitempty"` - }{Verdict: GateComposed, Summary: "judged by its group's composed check"} - v.RepoCheck = &struct { - Verdict string `json:"verdict"` - Summary string `json:"summary"` - }{Verdict: "pass", Summary: "the repository's own check passed"} - w.h.Checked(v) - } - w.settleAll() - if len(w.ctl.checks) != 1 || w.ctl.checks[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) { - t.Fatalf("the composed check was not asked for members judged by it: %v", w.ctl.checks) - } - // A member whose repository check failed is not passed, whatever the composed check will say. - w2 := newWorld(t) - w2.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go")) - v := judged(gCtl, 1, gHc, "pass", gTrunk0) - v.Gate = &struct { - Verdict string `json:"verdict"` - Summary string `json:"summary"` - Modules []string `json:"modules,omitempty"` - Dependents []string `json:"dependents,omitempty"` - }{Verdict: GateComposed} - v.RepoCheck = &struct { - Verdict string `json:"verdict"` - Summary string `json:"summary"` - }{Verdict: "fail", Summary: "a test failed"} - w2.h.Checked(v) - if w2.state(IDOf(gCtl, gHc)) == Ready { - t.Fatalf("a member whose repository check failed is ready") - } -} - -// Issue 1049 (survey question 1, confirmed here): a composed check asked after one member has already merged is asked -// with ALL the members, the merged one at the head it merged, never with the unmerged one alone. The controller needs two -// or more members to compose (mesh-controller delivery.go askGroupCheck), and a merge of a contained head is a no-op -// there. -func TestAComposedCheckAskedAfterOneMemberMergedIsAskedWithAllMembers(t *testing.T) { - w := approvedGroup(t) - w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain} - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - w.h.PullMerged(merged(gCat, 2, gHk, gT1)) - w.settleAll() - if g := w.h.groups["feat/g"]; g == nil || !g.Closed { - t.Fatalf("the group did not close when a member merged: %+v", g) - } - // The unmerged member gets a new head and its own verdict: the group's composed check is due again. - const gHc2 = "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3" - asked := len(w.ctl.checks) - w.h.PullUpdated(pr(gCtl, 1, gHc2, "feat/g", "internal/a.go")) - w.h.Checked(judged(gCtl, 1, gHc2, "pass", gT1)) - w.settleAll() - var composed []string - for _, c := range w.ctl.checks[asked:] { - if len(c) > 7 && c[:7] == "feat/g:" { - composed = append(composed, c) - } - } - if len(composed) != 1 || composed[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc2) { - t.Fatalf("the composed check was asked with %v; wanted once, with both members (the merged one at its head)", composed) - } -} - -// Issue 1082 (the 2026-10-17 23:48 group): a closed group's member whose merge the tool refused after its sibling merged -// asks the group's composed check again at once, and merges on its pass with the one Approve (ADR 0418 §4, ADR 0421). -func TestAClosedGroupsMemberWhoseMergeWasRefusedAsksTheComposedCheckAgainAtOnce(t *testing.T) { - w := approvedGroup(t) - w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain} - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - // The catalogue member merged, the controller's was refused. - w.h.PullMerged(merged(gCat, 2, gHk, gT1)) - asked := len(w.ctl.checks) - w.settleAll() - var composed []string - for _, c := range w.ctl.checks[asked:] { - if strings.HasPrefix(c, "feat/g:") { - composed = append(composed, c) - } - } - if len(composed) != 1 || composed[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) { - t.Fatalf("the refusal asked the composed check %v; wanted once, at once, with both members", composed) - } - // Not again while that check is on its way. - w.later(2 * time.Minute) - w.settleAll() - if got := w.ctl.checks[asked:]; len(got) != 1 { - t.Fatalf("asked again while the composed check was on its way: %v", got) - } - // It passes; the controller's member merges on the same Approve. - w.forge.refuseRepo = nil - g := w.h.groups["feat/g"] - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"}) - w.settleAll() - if d := w.delivery(IDOf(gCtl, gHc)); d.Ready.Merged == nil { - t.Fatalf("the refused member was not merged on the new composed verdict: %+v", d.Ready) - } - if w.op.count() != 1 { - t.Fatalf("a second Approve was asked: %d", w.op.count()) - } -} - // Issue 1082: a refusal loop is not silent. A merge refused past ten minutes in a closed group (ADR 0418 §3) is a stalled // line naming the member and the merge tool's last answer, which the controller raises as a condition. func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) { @@ -290,116 +103,6 @@ func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) { } } -// Issue 1047 (the live case of 2026-10-17 23:34, ADR 0395 §1, design 47): a group member that is ready when a third party's -// merge moves its trunk, with no Approve asked yet (the composed check has not passed), has its own check asked at once, -// and the Approve is asked once the group's composed check has passed on it. -func TestATrunkMoveAsksTheCheckOfAGroupMemberWhoseApproveIsNotAskedYet(t *testing.T) { - w := newWorld(t) - w.op.auto = false - w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go")) - w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json")) - w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) - w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0)) - w.settleAll() - if w.op.count() != 0 { - t.Fatalf("the composed check has not passed, yet the operator was asked: %d", w.op.count()) - } - asked := len(w.ctl.checks) - w.h.PullMerged(merged(gCat, 5, "5555555555555555", gT1)) - w.settleAll() - got := w.ctl.checks[asked:] - if len(got) != 1 || got[0] != ":"+IDOf(gCat, gHk) { - t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got) - } - if last := w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1]; last != gT1[:12] && last != gT1 { - t.Fatalf("its check was not asked against the trunk's head: %v", w.ctl.baseSHAs) - } - // Not asked again at the next look while it is on its way. - w.later(time.Second) - w.settleAll() - if len(w.ctl.checks[asked:]) != 1 { - t.Fatalf("asked twice: %v", w.ctl.checks[asked:]) - } - // The composed check that was on its way when the trunk moved answers, judged against the old trunk (ADR 0403 §6): it is - // out of date as it lands, so the group does not become ready on it and the operator is not asked. - g := w.h.groups["feat/g"] - w.later(time.Minute) - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the old trunk", - Members: []struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Trunk string `json:"trunk,omitempty"` - }{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]}, - {Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}}) - w.settleAll() - if w.op.count() != 0 { - t.Fatalf("the operator was asked on a composed pass judged against the old trunk: %d", w.op.count()) - } - // The moved member's fresh verdict arrives: the composed check is asked again, and only its pass asks the operator. - w.later(time.Minute) - composedBefore := len(w.ctl.checks) - w.h.Checked(judged(gCat, 2, gHk, "pass", gT1)) - w.settleAll() - last := w.ctl.checks[len(w.ctl.checks)-1] - if len(w.ctl.checks) == composedBefore || !strings.HasPrefix(last, "feat/g:") { - t.Fatalf("the composed check was not asked again on the fresh verdict: %v", w.ctl.checks[composedBefore:]) - } - if w.op.count() != 0 { - t.Fatalf("the operator was asked before the new composed check passed: %d", w.op.count()) - } - g = w.h.groups["feat/g"] - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the new trunk", - Members: []struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Trunk string `json:"trunk,omitempty"` - }{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]}, - {Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gT1[:12]}}}) - w.settleAll() - if w.op.count() != 1 { - t.Fatalf("the operator is asked once, on the new pass: %d", w.op.count()) - } -} - -// Issue 1047 (review round 2): the other order. The moved member's fresh verdict lands before the stale composed pass (one -// build against two): the pass is out of date as it lands, the composed check is asked again, and the operator is asked -// only after that check passes. -func TestAStaleComposedPassLandingAfterTheMovedMembersFreshVerdictIsAskedAgain(t *testing.T) { - w := newWorld(t) - w.op.auto = false - w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go")) - w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json")) - w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) - w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0)) - w.settleAll() - g := w.h.groups["feat/g"] - w.later(time.Minute) - w.h.PullMerged(merged(gCat, 5, "5555555555555555", gT1)) - w.settleAll() - w.later(time.Minute) - w.h.Checked(judged(gCat, 2, gHk, "pass", gT1)) // the fresh verdict first - w.settleAll() - w.later(time.Minute) - before := len(w.ctl.checks) - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the old trunk", - Members: []struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Trunk string `json:"trunk,omitempty"` - }{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]}, - {Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}}) - w.settleAll() - if len(w.ctl.checks) == before || !strings.HasPrefix(w.ctl.checks[len(w.ctl.checks)-1], "feat/g:") { - t.Fatalf("the composed check was not asked again after the stale pass: %v", w.ctl.checks[before:]) - } - if w.op.count() != 0 { - t.Fatalf("the operator was asked before the new composed check passed: %d", w.op.count()) - } -} - // ownPending is the merge tool's refusal of a member whose own mesh/repo-check stands pending after the group's composed // check set its gate (issue 1049: the forge's "not a success: mesh/repo-check (pending)"). var ownPending = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-controller#1 is not merged: main requires " + @@ -416,28 +119,3 @@ func ownChecks(w *world, from int) (own, composed []string) { } return } - -// Issue 1049 (the 2026-10-18 02:04 group): a closed group's member refused for a pending status of its OWN (the repository -// check a sibling's new head put back) asks its own check, once for the head, and not the composed check, which sets no -// repository check; a second pass at the same head asks nothing more. -func TestAClosedGroupsMemberRefusedForItsOwnPendingStatusAsksItsOwnCheckOncePerHead(t *testing.T) { - w := approvedGroup(t) - w.forge.refuseRepo = map[string]error{gCtl: ownPending} - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - w.h.PullMerged(merged(gCat, 2, gHk, gT1)) - asked := len(w.ctl.checks) - w.settleAll() - own, composed := ownChecks(w, asked) - if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) { - t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed) - } - // Later passes, the same head, the same refusal: nothing more is asked. - w.later(2 * time.Minute) - w.settleAll() - w.later(2 * time.Minute) - w.settleAll() - if got := len(w.ctl.checks) - asked; got != 1 { - t.Fatalf("asked %d checks at the same head, wanted the one: %v", got, w.ctl.checks[asked:]) - } -} 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 55e7cbdb..c0b8e873 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -623,16 +623,15 @@ func (h *Holder) takePlaceOf(old string, d *Delivery) bool { return false } -// decidedOnHead is the verdict the forge's statuses on a head hold, when the gate decided one: passed, warned -// or failed. Nil for none, pending, or a check that could not run — those are asked again. +// decidedOnHead is the verdict the forge's statuses on a head hold, when the checker decided one: passed, warned or +// failed. Nil for none, pending, or a check that could not run — the checker checks those again. func decidedOnHead(checks map[string]string, now time.Time) *Verdict { - switch checks["mesh/merge-gate"] { + switch checks[ctxCheck] { case "success", "warning", "failure": default: return nil } - return &Verdict{At: now, Gate: verdictOfState(checks["mesh/merge-gate"]), - Summary: "the head's mesh/merge-gate, as the forge holds it", Repo: verdictOfState(checks["mesh/repo-check"])} + return verdictOfCheck(checks[ctxCheck], "", now) } // joinGroup puts a new head in the group its branch name makes with the other repositories' open pull @@ -739,9 +738,8 @@ func (h *Holder) PullMerged(p PullEvent) { return } fresh.FromForge = true - if s := p.HeadChecks["mesh/merge-gate"]; s != "" { - fresh.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it", - Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])} + if s := p.HeadChecks[ctxCheck]; s != "" { + fresh.Check = verdictOfCheck(s, "", now) } d, ts = fresh, []Transition{t} if d.Check != nil { @@ -757,9 +755,8 @@ func (h *Holder) PullMerged(p PullEvent) { PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid, After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, FromForge: true} // Its check, as the forge holds it on the head: the verdict the pull request merged with. - if s := p.HeadChecks["mesh/merge-gate"]; s != "" { - d.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it", - Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])} + if s := p.HeadChecks[ctxCheck]; s != "" { + d.Check = verdictOfCheck(s, "", now) } t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: fmt.Sprintf("%s#%d merged; its head was not "+ "heard by this owner, so it is made from the forge's word", d.Repository, d.Number)}) @@ -904,151 +901,6 @@ func (h *Holder) PullClosed(p PullEvent) { h.mu.Unlock() } -// CheckedEvent is the controller's `checked`. -type CheckedEvent struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Number int `json:"number,omitempty"` - Commit string `json:"commit"` - Verdict string `json:"verdict"` - Summary string `json:"summary"` - ID string `json:"id"` - On string `json:"on,omitempty"` - Report string `json:"report,omitempty"` - Gate *struct { - Verdict string `json:"verdict"` - Summary string `json:"summary"` - Modules []string `json:"modules,omitempty"` - Dependents []string `json:"dependents,omitempty"` - } `json:"gate,omitempty"` - RepoCheck *struct { - Verdict string `json:"verdict"` - Summary string `json:"summary"` - } `json:"repo-check,omitempty"` - Plan *DeliveryPlan `json:"plan,omitempty"` - Group string `json:"group,omitempty"` - Members []struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Trunk string `json:"trunk,omitempty"` - } `json:"members,omitempty"` - // Trunk is the trunk commit the head was merged into and judged as, Beside the other repositories' commits the - // check read, and Took how long it ran (novox/hq ADR 0323 §3). - Trunk string `json:"trunk,omitempty"` - Beside map[string]string `json:"beside,omitempty"` - Took string `json:"took,omitempty"` -} - -// Checked takes a verdict: a head's own, or a group's composed check. -func (h *Holder) Checked(c CheckedEvent) { - h.mu.Lock() - defer h.mu.Unlock() - now := h.Now() - if c.Group != "" { - g := h.groups[c.Group] - if g == nil || g.Check == nil || g.Check.Asked != c.ID { - h.Logf("[mesh-delivery] a composed check of %s (%s) that is not the one asked: history", c.Group, c.ID) - return - } - g.Check.Verdict, g.Check.Summary = c.Verdict, c.Summary - for _, m := range h.membersOf(g) { - h.mergeAgainNow(m) // the group's composed check answered: its members' merges are asked at once - } - // Each member's head and the trunk commit it was merged into (novox/hq ADR 0323 §6). - for _, m := range c.Members { - for id := range g.Check.Commits { - if d := h.deliveries[id]; d != nil && strings.EqualFold(d.Repository, m.Owner+"/"+m.Repo) && - sameCommit(d.Commit, m.Commit) && m.Trunk != "" { - if g.Check.Trunks == nil { - g.Check.Trunks = map[string]string{} - } - g.Check.Trunks[id] = m.Trunk - } - } - } - // A composed check that was on its way when a trunk moved lands judged against the old trunk: out of date as it lands - // (novox/hq ADR 0403 §6, issue 1047), and asked again once a member is judged again. - if g.Check.OutOfDate == "" { - for id, judged := range g.Check.Trunks { - if d := h.deliveries[id]; d != nil && d.MergedAs == "" && !d.State.Final() && d.TrunkNow != "" && !sameCommit(judged, d.TrunkNow) { - g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was judged against trunk %s, the trunk is now %s", - id, shortOf(judged, 12), shortOf(d.TrunkNow, 12)) - // From when the composed check was asked: a member judged since then, even before this verdict landed, - // counts as judged again. - g.Check.OutOfDateAt = g.Check.At - h.Logf("[mesh-delivery] group %s: %s", g.ID, g.Check.OutOfDate) - break - } - } - } - g.Updated = now - h.keepGroup(g) - h.Logf("[mesh-delivery] group %s composed: %s — %s", g.ID, c.Verdict, c.Summary) - return - } - repository := strings.ToLower(c.Owner + "/" + c.Repo) - id := IDOf(repository, c.Commit) - d := h.deliveries[id] - if d == nil { - if c.Number == 0 { - return // a check of no pull request: nothing to deliver - } - d = &Delivery{ID: id, Repository: repository, Commit: c.Commit, Number: c.Number, Created: now} - t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: "its verdict arrived before its head was heard"}) - if err != nil { - h.refuse(err) - return - } - h.moved(d, []Transition{t}) - } - if d.Ready != nil && !d.Ready.OwnAskedAt.IsZero() { - // The own check asked for this head answered (issue 1792): one own ask is in flight at a time, so a status still - // pending or missing after its answer is asked again. - d.Ready.OwnAskedAt = time.Time{} - h.keep(d) - } - if d.State == Rejected && c.Trunk != "" && d.Check != nil && !sameCommit(d.Check.Trunk, c.Trunk) { - // Judged again against another trunk commit — the merge tool's ask of a verdict out of date, or a check asked - // elsewhere (novox/hq ADR 0323 §5, §7): taken as the recheck it is. - t, err := Apply(d, EvRecheck, true, Facts{Now: now, By: byOwner, Why: fmt.Sprintf("judged again against trunk %s "+ - "(it was rejected against trunk %s)", shortOf(c.Trunk, 12), shortOf(d.Check.Trunk, 12))}) - if err != nil { - h.refuse(err) - return - } - h.moved(d, []Transition{t}) - } - if d.State != Proposed { - h.Logf("[mesh-delivery] %s is %s: a verdict for it now is history (%s)", d.ID, d.State, c.Verdict) - return - } - v := &Verdict{ID: c.ID, On: c.On, At: now, Gate: c.Verdict, Summary: c.Summary, Report: c.Report, - Head: shortOf(c.Commit, 12), Trunk: c.Trunk, Beside: c.Beside, Took: c.Took} - if c.Gate != nil { - v.Gate, v.Summary = c.Gate.Verdict, c.Gate.Summary - v.Modules, v.Dependents = c.Gate.Modules, c.Gate.Dependents - } - if c.RepoCheck != nil { - v.Repo, v.RepoSaid = c.RepoCheck.Verdict, c.RepoCheck.Summary - } - if v.Gate == "" { - v.Gate = "error" - } - d.Check, d.AskedAgain = v, nil - h.mergeAgainNow(d) // a verdict taken: the merge is asked at once, not a minute after a refusal - if c.Plan != nil { - d.Plan = c.Plan - } - if why := d.outOfDate(); why != "" { - // Kept and shown, and it does not count: the delivery stays proposed (ADR 0323 §7). If it passes, its check is - // asked against the trunk's head before the operator is (ADR 0421 §3, askFresh). - d.FreshWanted = d.Check.Passes() - h.Logf("[mesh-delivery] %s: its verdict %s is %s", d.ID, c.ID, why) - } - h.settleAndKeep(d) -} - // WalkMoved takes a walk's record, from `plan-moved` or the controller's walks: the delivery it walks // moves with it; a backlog walk's carried builds are credited to the deliveries that published them. func (h *Holder) WalkMoved(w Walk) { @@ -1384,15 +1236,11 @@ func (h *Holder) plan() []func() { } } - // Proposed, with no verdict and no check asked: its check is asked at once (issue 1115), or the verdict that - // stands on its head at the forge is adopted. + // Proposed, with no verdict: the checker's mesh/check that stands on its head at the forge is adopted (issue 1115); + // nothing is asked of the controller (issue 1318). if a := h.askChecks(now); a != nil { asks = append(asks, a) } - // Proposed, its check asked and held by no entry of the build queue past the bound: asked again (issue 1274). - if a := h.lostChecks(now); a != nil { - asks = append(asks, a) - } // Merged with no walk yet: tied to the newest walk heard that answers it — by name, by its own commit, or by // the forge's word that the walk's commit contains it, asked here (novox/hq issue 362). @@ -1947,59 +1795,24 @@ func heldForGroup(d *Delivery, g *Group) bool { return d.State == Held && strings.HasPrefix(d.HeldWhy, fmt.Sprintf("its group %s's composed check", g.ID)) } -// standingVerdict is the passing verdict an open pull request's head holds at the forge, when every merge check and -// every context its base requires stands as success or warning; nil otherwise, a missing context included (issue 1115). -func standingVerdict(p OpenPull, now time.Time) *Verdict { - if p.Checks == nil { - return nil - } - contexts := []string{"mesh/merge-gate", "mesh/repo-check"} // the controller's; mesh/check is taken in askChecks - for _, c := range p.Required { - if !slices.Contains(contexts, c) { - contexts = append(contexts, c) - } - } - for _, c := range contexts { - if st := p.Checks[c].State; st != "success" && st != "warning" { - return nil - } - } - return &Verdict{At: now, Gate: verdictOfState(p.Checks["mesh/merge-gate"].State), - Summary: "the head's mesh/merge-gate, as the forge holds it", Repo: verdictOfState(p.Checks["mesh/repo-check"].State)} -} - // askedCheck is whether a proposed delivery's own check stands asked: a kept ask with no id is none. func askedCheck(d *Delivery) bool { return d.CheckAsk != nil && d.CheckAsk.ID != "" } -// askChecks are the asks of proposed deliveries with no verdict, at each look, at once (issue 1115): the forge's open -// pull requests are read once, a passing verdict standing on a head is adopted (the delivery goes on to ready with no -// new check), and a head with no check asked is asked its check. Under the lock; the asks run outside it. +// askChecks reads the forge's open pull requests once for the proposed deliveries with no verdict (issue 1115), and takes the +// checker's mesh/check where one stands on a head (issue 1302); the base's required statuses are kept. Nothing is asked of +// the controller: the checker checks every head on every move (ADR 0447 §4, issue 1318). Under the lock; the read runs outside it. func (h *Holder) askChecks(now time.Time) func() { type want struct { id, owner, repo, head string number int - member Member - ask bool - why string } var wants []want for _, d := range h.deliveries { if d.State != Proposed || d.Check != nil || d.MergedAs != "" || d.Number == 0 { continue } - w := want{id: d.ID, head: d.Commit, number: d.Number, member: MemberOf(d)} + w := want{id: d.ID, head: d.Commit, number: d.Number} w.owner, w.repo, _ = strings.Cut(d.Repository, "/") - // A base that requires mesh/check is the checker's: nothing is asked of the controller (issue 1302). - if !askedCheck(d) && !d.judgedByCheck() { - if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute { - h.lookAt(tried.Add(time.Minute), "a proposed delivery's check that could not be asked") - } else { - w.ask = true - h.askTried[d.ID] = now - h.lookAt(now.Add(time.Minute), "a proposed delivery's check that could not be asked") - w.why = fmt.Sprintf("proposed for %s with no verdict and no check asked", now.Sub(d.Since).Round(time.Second)) - } - } wants = append(wants, w) } if len(wants) == 0 { @@ -2007,77 +1820,25 @@ func (h *Holder) askChecks(now time.Time) func() { } return func() { open, err := h.Forge.OpenPulls() + if err != nil { + return + } for _, w := range wants { - var standing *Verdict - oneStatus := false - if err == nil { - for _, p := range open { - if p.Number == w.number && strings.EqualFold(p.Owner, w.owner) && strings.EqualFold(p.Repo, w.repo) && - sameCommit(p.HeadSHA, w.head) { - if slices.Contains(p.Required, ctxCheck) { - // Its base requires mesh/check (issue 1302): its verdict is that status, when it stands, and - // nothing is asked of the controller. - oneStatus = true - h.mu.Lock() - h.noteRequired(h.deliveries[w.id], p.Required) - if c := p.Checks[ctxCheck]; c.State != "" { - h.takeCheckStatus(w.owner+"/"+w.repo, w.head, c.State, c.Description) - } - h.mu.Unlock() - continue - } - standing = standingVerdict(p, h.Now()) + for _, p := range open { + if p.Number == w.number && strings.EqualFold(p.Owner, w.owner) && strings.EqualFold(p.Repo, w.repo) && + sameCommit(p.HeadSHA, w.head) { + h.mu.Lock() + h.noteRequired(h.deliveries[w.id], p.Required) + if c := p.Checks[ctxCheck]; c.State != "" { + h.takeCheckStatus(w.owner+"/"+w.repo, w.head, c.State, c.Description) } + h.mu.Unlock() } } - if oneStatus { - continue - } - if standing != nil { - h.mu.Lock() - if d := h.deliveries[w.id]; d != nil && d.State == Proposed && d.Check == nil { - delete(h.askTried, w.id) - d.Check = standing - h.Logf("[mesh-delivery] %s: a passing verdict already stands on its head at the forge; adopted, no check asked", w.id) - h.settleAndKeep(d) - } - h.mu.Unlock() - continue - } - if w.ask { - h.askOwnCheck(w.id, w.member, w.why) - } } } } -// askOwnCheck asks the controller a proposed delivery's own check and keeps the ask. -func (h *Holder) askOwnCheck(id string, m Member, why string) { - asked, err := h.Controller.Check("", []Member{m}) - h.mu.Lock() - defer h.mu.Unlock() - if err != nil { - h.Logf("[mesh-delivery] %s's check cannot be asked yet: %v", id, err) - return - } - if asked == "" { - h.Logf("[mesh-delivery] ERROR: %s's check was asked and the controller's answer held no check id: it is not taken as asked, "+ - "and asked again in a minute at most", id) - return - } - delete(h.askTried, id) - d := h.deliveries[id] - if d == nil || d.State != Proposed || d.Check != nil || askedCheck(d) { - return - } - d.CheckAsk = &CheckAsk{ID: asked, At: h.Now(), By: byOwner, Why: why} - h.Logf("[mesh-delivery] %s: its check asked of the controller as %s — %s", id, asked, why) - d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Since: h.Now(), - Line: oneLine(fmt.Sprintf("%s delivery %s: its check asked by %s as %s: %s", h.Now().UTC().Format(time.RFC3339), - id, byOwner, asked, why))}) - h.keep(d) -} - // adopt makes a delivery of a walk no pull request is known for: running already — at the switch, or on the // controller's own path — it is delivering; waiting for a word, it waits for a person. func (h *Holder) adopt(w Walk) { @@ -2168,69 +1929,10 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func( h.keepGroup(g) return nil } - commits := commitsOf(members) - // The composed check, once every member is ready. Asked again for new heads, even once the - // group is closed: a member rebased or pushed is a new head, and the group waits for its composed check rather - // than being held for a person (novox/hq ADR 0323 §6, issue 406). One out of date is asked again once a member - // was judged again after it went out of date: a recheck, or the merge tool's ask. - allReady := true - for _, d := range members { - // In a closed group a member on the trunk counts — merged and on its way, or already delivered (reached). - if d.State != Ready && !(g.Closed && d.MergedAs != "" && (!d.State.Final() || d.State.Reached())) { - allReady = false - } - } - // A group judged by mesh/check (issue 1302, ADR 0447 §2): its verdict is the checker's one verdict at its members' - // heads, read, not asked; nothing below asks a composed check or owes a member's mesh/merge-gate. - oneStatus := oneStatusGroup(members) - if oneStatus { - h.composeFromChecker(g, members, now) - } - again := !oneStatus && g.Check != nil && (!sameMembers(g.Check.Commits, commits) || g.Check.OutOfDate != "" && rejudgedSince(members, g.Check.OutOfDateAt)) - // A closed group's member whose merge the tool refused, after the composed check answered, is judged again at once - // (novox/hq ADR 0418 §4, issue 1082): the refusal is a sibling's merge having moved what the verdict stands on, and - // nothing else asks the composed check again. Not while a check is on its way (its verdict is empty then). - // **A refusal that names the member's OWN pending status** (mesh/repo-check, with the gate not pending: the composed - // check set it) is not that: the composed check sets no repository check, so asking it again changes nothing. The - // member's own check is asked, once for the head (issue 1049); its verdict sets the status. - if !oneStatus && !again && g.Closed && g.Check != nil && g.Check.Verdict != "" { - for _, d := range members { - if !refusedAfter(d, g.Check.At) { - continue - } - if ownStatusPending(d.Ready.Answer) { - if a := h.askOwnAfterRefusal(d); a != nil { - asks = append(asks, a) - } - continue - } - again = true - h.Logf("[mesh-delivery] group %s: the merge of %s was refused (%s), so its composed check is asked again", g.ID, - d.ID, d.Ready.Answer) - break - } - } - if !oneStatus && allReady && (g.Check == nil && !g.Closed || again) { - ms := membersOfAsk(members) - id := g.ID - asks = append(asks, func() { - asked, err := h.Controller.Check(id, ms) - h.mu.Lock() - defer h.mu.Unlock() - g := h.groups[id] - if g == nil { - return - } - if err != nil { - h.Logf("[mesh-delivery] group %s's composed check cannot be asked yet: %v", id, err) - h.again("a group's composed check that could not be asked") - return - } - g.Check = &GroupCheck{Asked: asked, At: h.Now(), Commits: commits} - g.Updated = h.Now() - h.keepGroup(g) - }) - } + // A group is judged by mesh/check (issue 1302, ADR 0447 §2): its verdict is the checker's one verdict at its members' + // heads, read, not asked; nothing asks a composed check of the controller and no member is owed a mesh/merge-gate + // (issue 1318). + h.composeFromChecker(g, members, now) // A superseded member of a closed group: whether a later delivered walk on its trunk carried every module it // moves, ADR 0270's covered test, kept on it as CoveredBy. Its own state stays superseded — a final state never // moves — and its group counts it as reached (novox/hq issue 442). @@ -2277,26 +1979,6 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func( g.Updated = now h.keepGroup(g) } - // Each member's mesh/merge-gate is the composed check's (ADR 0403 §1, §6): pending, saying why, while no composed - // verdict stands at these heads. The verdict itself sets it, success or failure, in the forge module. - if why := composedPending(g, members); why != "" && !oneStatus { - if g.SaidGate == nil { - g.SaidGate = map[string]string{} - } - for _, d := range members { - if d.MergedAs != "" || d.State.Final() || g.SaidGate[d.ID] == why { - continue - } - g.SaidGate[d.ID] = why - g.Owed = append(g.Owed, Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/merge-gate", State: "pending", - Line: clip("group "+g.ID+": "+why, 140), Since: now, Last: d.Repository + "#" + fmt.Sprint(d.Number) + " " + d.HTMLURL}) - g.Updated = now - h.keepGroup(g) - } - } else if len(g.SaidGate) > 0 { - g.SaidGate = nil - h.keepGroup(g) - } // Its word, once, when every member is merged (ADR 0418 §2). if g.Closed { asks = append(asks, h.groupWord(g, members)...) @@ -2304,22 +1986,6 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func( return asks } -// composedPending is why a group member's gate stands pending: the composed check has given no verdict at the members' -// heads as they are, or its verdict is out of date; empty once one stands, which the forge module then sets as every -// member's gate (novox/hq ADR 0403 §6). -func composedPending(g *Group, members []*Delivery) string { - c := g.Check - switch { - case c == nil || c.Verdict == "": - return "its group's composed check has not given a verdict at the members' heads yet" - case !sameMembers(c.Commits, commitsOf(members)): - return "composed again for the group's new heads" - case c.OutOfDate != "": - return c.OutOfDate - } - return "" -} - // membersOfAsk are a group's members as the controller is asked about them: by id. No order is asked for or kept; the // plan's tiers take it from the module graph (novox/hq ADR 0418 §2, ADR 0419). func membersOfAsk(members []*Delivery) []Member { @@ -2823,50 +2489,3 @@ func refusedAfter(d *Delivery, at time.Time) bool { r := d.Ready return r != nil && r.Merged == nil && d.MergedAs == "" && r.RefusedSince != nil && !r.LastTry.Before(at) } - -// ownStatusPending is whether the merge tool refused a merge because a status of the member's own merge check is -// pending or missing (issue 1276) while its gate is not: the forge's "not a success: mesh/repo-check (pending)" (gitea merge.ts, mergeableUnder). -// A refusal that also names mesh/merge-gate is the group's composed check's to answer, as is a verdict out of date. -func ownStatusPending(answer string) bool { - _, listed, ok := strings.Cut(answer, "not a success:") - return ok && (strings.Contains(listed, "mesh/repo-check (pending)") || strings.Contains(listed, "mesh/repo-check (missing)")) && - !strings.Contains(listed, "mesh/merge-gate") -} - -// askOwnAfterRefusal asks a member's own check once for its head, after the merge tool refused it for a pending status of -// its own. Under the lock; the ask is made outside it. Nil when it was asked already for this head. -func (h *Holder) askOwnAfterRefusal(d *Delivery) func() { - r := d.Ready - if !r.OwnAskedAt.IsZero() || d.judgedByCheck() { - return nil // asked already; or the checker's, who checks it again on its own (issue 1302) - } - r.OwnAskedAt = h.Now() - h.keep(d) - id, m := d.ID, MemberOf(d) - return func() { - asked, err := h.Controller.Check("", []Member{m}) - h.mu.Lock() - defer h.mu.Unlock() - d := h.deliveries[id] - if err != nil { - h.Logf("[mesh-delivery] %s's own check cannot be asked yet: %v", id, err) - if d != nil && d.Ready != nil { - d.Ready.OwnAskedAt = time.Time{} - h.keep(d) - } - h.again("a member's own check that could not be asked") - return - } - h.Logf("[mesh-delivery] %s: the merge tool refused it for a pending status of its own, so its own check is asked as %s", - id, asked) - } -} - -func rejudgedSince(members []*Delivery, at time.Time) bool { - for _, d := range members { - if d.Check != nil && d.Check.At.After(at) { - return true - } - } - return false -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go index 6204e918..cf164e2a 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder_test.go @@ -251,21 +251,21 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) { } w.store.failing = false w.settleAll() - if len(w.said) != 1 || len(w.forge.notesOn(head)) != 2 { + if len(w.said) != 1 || len(w.forge.notesOn(head)) != 1 { t.Fatalf("once kept, said %d and noted %d", len(w.said), len(w.forge.notesOn(head))) } // A bucket the bus cannot take is written again; a note is appended once however often it is asked. w.owed.down = true w.h.Checked(verdict("novox/app", 7, head, "pass")) w.settleAll() - if len(w.forge.notesOn(head)) != 2 { + if len(w.forge.notesOn(head)) != 1 { t.Fatal("noted while the bus was away") } w.owed.down = false w.later(time.Hour) // each failure put its next try later: past every one of them w.settleAll() w.settleAll() - if notes := w.forge.notesOn(head); len(notes) != 4 { + if notes := w.forge.notesOn(head); len(notes) != 3 { t.Fatalf("after the bus came back the note has %d line(s): %q", len(notes), notes) } if d := w.delivery(IDOf("novox/app", head)); len(d.Owed) != 0 { @@ -343,7 +343,7 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) { } // Never heard: the forge's statuses on its head decide. m := merged("novox/lab", 3, "eeeeeeeeeeee5555", "ffffffffffff6666") - m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "warning"} + m.HeadChecks = map[string]string{"mesh/check": "success"} m.Paths = []string{"README.md"} w.h.PullMerged(m) w.settleAll() @@ -354,68 +354,6 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) { } } -// A delivery group (ADR 0239 decision 4, ADR 0418): two heads sharing a branch name across repositories, checked -// together, ready only when both are and their composition passed — and let go for delivery once, when every member is -// merged, whatever order they merged in. No member waits for another's delivery. -func TestAGroupIsCheckedTogetherAndLetGoOnceEveryMemberIsMerged(t *testing.T) { - w := newWorld(t) - const ctlHead, catHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2" - ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead) - w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go")) - w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")) - // These heads are not full commit ids, so their description lines cannot name them: the reviews are set as read. - w.h.deliveries[ctl].ReviewedBy, w.h.deliveries[ctl].ReviewedAt = "tester@shanks", ctlHead - w.h.deliveries[cat].ReviewedBy, w.h.deliveries[cat].ReviewedAt = "tester@shanks", catHead - if w.delivery(ctl).Group != "feat/x" || w.delivery(cat).Group != "feat/x" { - t.Fatalf("one branch name in two repositories made no group: %q %q", w.delivery(ctl).Group, w.delivery(cat).Group) - } - w.h.Checked(verdict("novox/mesh-controller", 1, ctlHead, "pass")) - w.settleAll() - if len(composed(w.ctl.checks)) != 0 { - t.Fatal("the group was checked composed before every member was ready") - } - w.h.Checked(verdict("novox/mesh-catalog", 2, catHead, "pass")) - w.settleAll() - if c := composed(w.ctl.checks); len(c) != 1 || c[0] != "feat/x:"+cat+","+ctl { - t.Fatalf("the composed check asked %v, wanted the members by id", w.ctl.checks) - } - if state, _ := GroupState(w.h.groups["feat/x"], w.h.membersOf(w.h.groups["feat/x"])); state != "checking" { - t.Fatalf("before its composed verdict the group is %s", state) - } - w.h.Checked(CheckedEvent{Group: "feat/x", ID: w.h.groups["feat/x"].Check.Asked, Verdict: "pass", Summary: "both compose"}) - w.settleAll() - if state, _ := GroupState(w.h.groups["feat/x"], w.h.membersOf(w.h.groups["feat/x"])); state != "ready" { - t.Fatalf("composed and ready, the group is %s", state) - } - if !strings.HasPrefix(w.forge.statuses["novox/mesh-catalog@"+catHead+" mesh/delivery-group"], "success") { - t.Fatalf("the group's status on a member's head: %v", w.forge.statuses) - } - - // The catalogue merges first; its walk waits — the controller's member is not merged yet, so nothing is let go. - w.h.PullMerged(merged("novox/mesh-catalog", 2, catHead, "m2m2m2m2m2m2")) - w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", "novox/mesh-catalog", "m2m2m2m2m2m2", true))) - w.settleAll() - if w.state(cat) != Published || len(w.ctl.delivers) != 0 { - t.Fatalf("a member was let go before every member merged: %s %v", w.state(cat), w.ctl.delivers) - } - // The controller merges: its walk is on the controller's own path, started at the merge. Every member is merged, - // so the catalogue's walk is let go at once, without waiting for the controller's delivery. - w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1")) - ctlWalk := w.ctl.put(aWalk("plan-ctl", "novox/mesh-controller", "m1m1m1m1m1m1", false)) - w.h.WalkMoved(ctlWalk) - w.settleAll() - if w.state(ctl) != Delivering || len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-cat" { - t.Fatalf("every member merged, the catalogue was not let go once: %s %v", w.state(ctl), w.ctl.delivers) - } - if w.state(cat) != Delivering { - t.Fatalf("the catalogue's member is %s", w.state(cat)) - } - w.settleAll() - if len(w.ctl.delivers) != 1 { - t.Fatalf("the word was given more than once: %v", w.ctl.delivers) - } -} - // A member that fails stops no other (ADR 0418 §5): the plan that fails fails as every plan does, and what was delivered // stays. Nothing is stopped member by member. func TestAFailedMemberStopsNoOtherMember(t *testing.T) { @@ -471,12 +409,6 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T) w.settleAll() w.later(time.Minute) w.settleAll() - if len(w.ctl.checks) != 1 { - t.Fatalf("the composed check was not asked once: %v", w.ctl.checks) - } - if got := w.ctl.checks[0]; got != "feat/z:"+IDOf("novox/a", "a1a1a1a1a1a1a1")+","+IDOf("novox/b", "b2b2b2b2b2b2b2") { - t.Fatalf("the members asked: %s", got) - } said := 0 for _, c := range w.forge.comments { if strings.Contains(c, "not read") && strings.Contains(c, "novox/a#1") { @@ -502,43 +434,6 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T) } } -// A group's members have their mesh/merge-gate said pending, saying why, until a composed verdict stands at their heads -// (ADR 0403 §1, §6); the verdict then sets the gate in the forge module. -func TestAGroupMembersGateIsPendingUntilItsComposedVerdictStands(t *testing.T) { - w := newWorld(t) - w.op.auto = false - const ha, hb = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" - w.h.PullUpdated(pr("novox/a", 1, ha, "feat/z")) - w.h.PullUpdated(pr("novox/b", 2, hb, "feat/z")) - w.h.Checked(verdict("novox/a", 1, ha, "pass")) - w.h.Checked(verdict("novox/b", 2, hb, "pass")) - w.settleAll() - for _, at := range []string{"novox/a@" + ha, "novox/b@" + hb} { - if s := w.forge.statuses[at+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") || !strings.Contains(s, "composed check") { - t.Fatalf("the member's gate on %s is %q, wanted pending with its reason", at, s) - } - } - // Said once while the reason stands. - w.later(time.Minute) - before := w.forge.statusWrites - w.settleAll() - if w.forge.statusWrites != before { - t.Fatalf("the pending gate was written again with no change") - } - // A composed verdict stands: mesh-delivery says nothing more of the gate (the forge module sets it). - w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "pass", Summary: "compose"}) - w.settleAll() - if g := w.h.groups["feat/z"]; len(g.SaidGate) != 0 { - t.Fatalf("a standing verdict left a gate pending: %v", g.SaidGate) - } - // A member's push puts it back to pending. - w.h.PullUpdated(pr("novox/a", 1, "a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9", "feat/z")) - w.settleAll() - if s := w.forge.statuses["novox/b@"+hb+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") { - t.Fatalf("the sibling's gate after a push is %q", s) - } -} - // Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that. func TestStalledAndCloseWorkFromTheTable(t *testing.T) { w := newWorld(t) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1274_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1274_test.go deleted file mode 100644 index 170452c7..00000000 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1274_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" -) - -// Issue 1274: a proposed delivery whose asked check the build queue no longer holds is asked again, and a recheck from -// proposed is taken when no check of its head is waiting or in flight. - -// 1274/AC1: the queue holds nothing for the ask, the sweep runs after the bound: the check is asked once more and the -// transition says the earlier ask was lost. -func TestACheckTheQueueLostIsAskedAgainBySweepAfterTheBound(t *testing.T) { - w := newWorld(t) - id := IDOf("novox/app", head) - w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) - w.settleAll() - first := w.delivery(id).CheckAsk - if len(w.ctl.checks) != 1 || first == nil { - t.Fatalf("asked %v, ask %+v", w.ctl.checks, first) - } - w.ctl.queue = nil // the queue lost it - w.later(9 * time.Minute) - w.settleAll() - if len(w.ctl.checks) != 1 { - t.Fatalf("asked again inside the bound: %v", w.ctl.checks) - } - w.later(2 * time.Minute) - w.settleAll() - d := w.delivery(id) - last := d.Transitions[len(d.Transitions)-1] - if len(w.ctl.checks) != 2 || d.CheckAsk == nil || d.CheckAsk.ID == first.ID || last.Event != EvLost || - !strings.Contains(last.Why, first.ID) || !strings.Contains(last.Why, "lost") { - t.Fatalf("asked %v; ask %+v; last transition %+v", w.ctl.checks, d.CheckAsk, last) - } - w.later(time.Hour) - w.settleAll() - if len(w.ctl.checks) != 2 { - t.Fatalf("an ask the queue holds was asked again: %v", w.ctl.checks) - } -} - -// 1274/AC1: a verdict that went out of date, its check against the trunk's head asked and lost, is asked again too (the -// case of novox/mesh-controller@28a278c1462a), and the ask's bound runs from the new ask. -func TestACheckAgainstTheTrunkTheQueueLostIsAskedAgain(t *testing.T) { - w := approvedGroup(t) - id := IDOf(gCtl, gHc) - w.h.PullMerged(merged(gCtl, 5, "5555555555555555", gT1)) - w.settleAll() - d := w.delivery(id) - if d.State != Proposed || d.AskedAgain == nil { - t.Fatalf("it is %s, asked again %v", d.State, d.AskedAgain) - } - asked := len(w.ctl.checks) - w.ctl.queue = nil - w.later(11 * time.Minute) - w.settleAll() - d = w.delivery(id) - last := d.Transitions[len(d.Transitions)-1] - if len(w.ctl.checks) != asked+1 || last.Event != EvLost || d.AskedAgain == nil || !d.AskedAgain.After(w.now.Add(-time.Minute)) { - t.Fatalf("asked %v; last transition %+v; asked again %v", w.ctl.checks[asked:], last, d.AskedAgain) - } -} - -// 1274/AC2: the queue still holds the ask, waiting or in flight, or cannot be read: nothing is asked again. -func TestACheckTheQueueStillHoldsIsNotAskedAgain(t *testing.T) { - w := newWorld(t) - id := IDOf("novox/app", head) - w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) - w.settleAll() - w.ctl.queue[0].State = "in flight" - w.later(3 * time.Hour) - w.settleAll() - if len(w.ctl.checks) != 1 { - t.Fatalf("an ask in flight was asked again: %v", w.ctl.checks) - } - w.ctl.queue = nil - w.ctl.queueDown = true - w.later(time.Hour) - w.settleAll() - if len(w.ctl.checks) != 1 || w.delivery(id).State != Proposed { - t.Fatalf("an unreadable queue asked again: %v", w.ctl.checks) - } - w.ctl.queueDown = false - w.later(3 * time.Minute) - w.ctl.queue = []QueuedAsk{{ID: "elsewhere", State: "dead", Kind: "check", Repository: "novox/app", Commit: head}} - w.settleAll() - if len(w.ctl.checks) != 2 { - t.Fatalf("a dead ask counted as held: %v", w.ctl.checks) - } -} - -// 1274/AC3: recheck of a proposed delivery with no check waiting or in flight is taken and asks its check; with one -// held it is refused naming it. -func TestARecheckFromProposedIsTakenOnlyWhenNoCheckOfItsHeadIsHeld(t *testing.T) { - w := newWorld(t) - id := IDOf("novox/app", head) - w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) - w.settleAll() - held := w.delivery(id).CheckAsk.ID - if _, err := w.h.Recheck(id, "nothing came", "jochen"); err == nil || !strings.Contains(err.Error(), held) { - t.Fatalf("a recheck with a check waiting: %v", err) - } - if _, err := w.h.Recheck(id, "", "jochen"); err == nil { - t.Fatal("a recheck with no why was taken") - } - if len(w.ctl.checks) != 1 { - t.Fatalf("a refused recheck asked: %v", w.ctl.checks) - } - w.ctl.queue = nil - said, err := w.h.Recheck(id, "nothing came", "jochen") - d := w.delivery(id) - last := d.Transitions[len(d.Transitions)-1] - if err != nil || len(w.ctl.checks) != 2 || d.State != Proposed || last.Event != EvLost || last.By != "jochen" || - !strings.Contains(last.Why, "nothing came") || d.CheckAsk.ID != "check-2" || !strings.Contains(said, "check-2") { - t.Fatalf("%q %v; asked %v; last transition %+v", said, err, w.ctl.checks, last) - } - // Taken, it is now held: a second recheck is refused. - if _, err := w.h.Recheck(id, "again", "jochen"); err == nil || !strings.Contains(err.Error(), "check-2") { - t.Fatalf("a second recheck: %v", err) - } -} - -// 1274/AC4: a group member re-asked this way has its group's composed check asked again at the members' heads once its -// own check answers. -func TestAGroupMemberReaskedAfterALostCheckHasTheComposedCheckAskedAgain(t *testing.T) { - w := approvedGroup(t) - id := IDOf(gCat, gHk) - if _, err := w.h.Recheck(id, "its verdict is wanted again", "jochen"); err != nil { - t.Fatal(err) - } - w.settleAll() // its own check is recorded as asked - w.h.mu.Lock() - w.h.groups["feat/g"].Check.OutOfDate = "" // as if the composed check stood: only the lost ask's own act marks it - w.h.mu.Unlock() - w.ctl.queue = nil - w.later(11 * time.Minute) - w.settleAll() - g := w.h.groups["feat/g"] - if g.Check.OutOfDate == "" || !strings.Contains(g.Check.OutOfDate, "lost") { - t.Fatalf("the composed check stands: %+v", g.Check) - } - first := g.Check.Asked - asked := len(w.ctl.checks) - w.later(time.Minute) - w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0)) - w.settleAll() - last := w.ctl.checks[len(w.ctl.checks)-1] - if len(w.ctl.checks) <= asked || !strings.HasPrefix(last, "feat/g:") || w.h.groups["feat/g"].Check.Asked == first { - t.Fatalf("the composed check was not asked again: %v", w.ctl.checks[asked:]) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go index e26f5b17..61c318e3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go @@ -75,22 +75,6 @@ func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing } } -// 1275/AC2 with 1276/AC1: a member whose own repository check is the missing status has that check asked, once. -func TestAHeldGroupWhoseMemberLacksItsOwnRepositoryCheckAsksThatCheck(t *testing.T) { - w := approvedGroup(t) - allGreen(w) - w.op.answer(w.op.open()[0], OptionApprove) - setChecks(w, gCtl, gHc, "success", "") - asked := len(w.ctl.checks) - w.settleAll() - w.later(time30s) - w.settleAll() - own, composed := ownChecks(w, asked) - if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) { - t.Fatalf("own checks %v, composed %v; wanted the member's own check once", own, composed) - } -} - // 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said urgent, // naming the group, the refused member and the forge's words. func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) { @@ -141,42 +125,6 @@ func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) { } } -// 1275/AC1: a group with a member whose required status is not a success is not asked its Approve; the group says which -// member and status; it is asked once every member may merge. -func TestAGroupIsNotAskedWhileAMembersRequiredStatusIsNotASuccess(t *testing.T) { - w := newWorld(t) - w.op.auto = false - w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go")) - w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json")) - w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) - w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0)) - w.settleAll() - g := w.h.groups["feat/g"] - setChecks(w, gCat, gHk, "success", "success") // the composed verdict set every member's gate - setChecks(w, gCtl, gHc, "success", "pending") - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed", - Members: []struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Trunk string `json:"trunk,omitempty"` - }{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]}, - {Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}}) - w.settleAll() - if w.op.count() != 0 { - t.Fatalf("the operator was asked with a required status pending: %d asks", w.op.count()) - } - if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Member != IDOf(gCtl, gHc) || !strings.Contains(g.Hold.Says, "mesh/repo-check (pending)") { - t.Fatalf("the group does not say which member and status it waits for: %+v", g.Hold) - } - setChecks(w, gCtl, gHc, "success", "success") - w.later(time30s) - w.settleAll() - if w.op.count() != 1 || g.Hold != nil { - t.Fatalf("asks %d, hold %+v; wanted the group asked once its statuses are green", w.op.count(), g.Hold) - } -} - // 1275/AC5: a member's required status turning not-success while the ask waits withdraws the ask at the router; the // answer to the withdrawn ask merges nothing; the group is asked again once every member may merge. func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t *testing.T) { @@ -205,23 +153,6 @@ func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t } } -// 1276/AC1: given a closed group whose member's merge was refused with "not a success: mesh/repo-check (missing)", then -// the member's own check is asked, not the composed check. -func TestAClosedGroupsMemberRefusedForItsOwnMissingStatusAsksItsOwnCheck(t *testing.T) { - w := approvedGroup(t) - allGreen(w) - w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")} - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - w.h.PullMerged(merged(gCat, 2, gHk, gT1)) - asked := len(w.ctl.checks) - w.settleAll() - own, composed := ownChecks(w, asked) - if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) { - t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed) - } -} - // 1275/AC3 (review round 1): a member that is both refused and held for a required status is said by one stalled line, // not two with the same id. func TestAMemberBothRefusedAndHeldForAStatusIsOneStalledLine(t *testing.T) { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go index 195bef45..5ee6707d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1349_test.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "go/ast" "go/parser" "go/token" @@ -96,8 +95,9 @@ func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) { } forbidden := []string{"delivery-plan", "gitea_commit_status\"", "gitea_delivery_view", "gitea_note_append", "gitea_add_comment", "gitea_merge_pull_request\"", "gitea_open_pulls_write"} - // The controller's verbs the gate calls: ADR 0450 §7. - allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "plans", "queue"} + // The controller's verbs the gate calls: ADR 0450 §7, less the checks and the queue, which the controller no longer + // keeps (issue 1318, ADR 0524 §5). + allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "plans"} called := map[string]bool{} fset := token.NewFileSet() for _, f := range files { @@ -132,7 +132,7 @@ func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) { return true }) } - for _, verb := range []string{"plans", "delivery-check"} { + for _, verb := range []string{"plans"} { if !called[verb] { t.Errorf("the gate calls no %s: the test reads nothing", verb) } @@ -194,38 +194,3 @@ func TestADeliveryWithNoPlanGetsItFromPlansAsData(t *testing.T) { t.Fatalf("the delivery's plan is %+v", d.Plan) } } - -// AC4: a delivery-check answer that holds no id is an error said aloud, naming the delivery, and not taken as asked. -func TestAnEmptyDeliveryCheckAnswerIsAnErrorNamingTheDelivery(t *testing.T) { - stub := &stubController{answer: `{"ok":true,"output":"","answer":{}}`} - if id, err := (seatController{ask: stub.ask}).Check("", []Member{{ID: "novox/app@x"}}); err == nil || id != "" || - !strings.Contains(err.Error(), "no check id") { - t.Fatalf("an empty check answer gave %q and %v", id, err) - } - w := newWorld(t) - var said []string - var mu sync.Mutex - w.h.Logf = func(format string, a ...any) { - mu.Lock() - defer mu.Unlock() - said = append(said, strings.TrimSpace(fmt.Sprintf(format, a...))) - } - w.h.Controller = seatController{ask: stub.ask} - w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go")) - w.settleAll() - id := IDOf("novox/app", head) - mu.Lock() - defer mu.Unlock() - found := false - for _, line := range said { - if strings.Contains(line, id) && strings.Contains(line, "no check id") && !strings.Contains(line, "taken as refused") { - found = true - } - } - if !found { - t.Fatalf("no error naming %s and the empty answer was logged: %q", id, said) - } - if d := w.delivery(id); d.CheckAsk != nil { - t.Fatalf("an empty answer was kept as an ask: %+v", d.CheckAsk) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1792_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1792_test.go index 1c9e0852..df16fa83 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1792_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1792_test.go @@ -4,42 +4,6 @@ import "testing" const gHk2 = "f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4" -// Issue 1792: a sibling's new head put a member's own mesh/repo-check back to pending ("out of date: judged beside ..."), -// and nothing asked it again. The composed check passed, the group stayed held ("unasked") on the member's pending -// status, and the operator's Approve was never asked. The group's hold on a member's own pending repository check asks -// that check, once for the head, with no composed check (ADR 0382 §2: each member's own check stays required). -func TestAGroupHeldBeforeItsAskOnAMembersOwnPendingRepoCheckAsksThatCheckOnce(t *testing.T) { - w := newWorld(t) - w.op.auto = false - w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go")) - w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json")) - w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) - w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0)) - w.settleAll() - g := w.h.groups["feat/g"] - setChecks(w, gCat, gHk, "success", "success") - setChecks(w, gCtl, gHc, "success", "pending") - asked := len(w.ctl.checks) - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed", - Members: []struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Trunk string `json:"trunk,omitempty"` - }{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]}, - {Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}}) - w.settleAll() - w.later(time30s) - w.settleAll() - own, composed := ownChecks(w, asked) - if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) { - t.Fatalf("own checks %v, composed %v; wanted the member's own check asked once", own, composed) - } - if w.op.count() != 0 { - t.Fatalf("the operator was asked with a required status pending: %d", w.op.count()) - } -} - func composedPassAt(w *world, hc, hk string) { g := w.h.groups["feat/g"] w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed", @@ -73,48 +37,3 @@ func heldOnOwnPending(t *testing.T) *world { } return w } - -// A second sibling push puts the member's repo-check out of date again after its own check answered: asked again. -func TestAMembersOwnCheckIsAskedAgainWhenASecondSiblingHeadPutsItOutOfDate(t *testing.T) { - w := heldOnOwnPending(t) - w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) // its own check answered - setChecks(w, gCtl, gHc, "success", "success") - w.settleAll() - w.h.PullUpdated(pr(gCat, 2, gHk2, "feat/g", "modules/app/module.json")) - w.h.Checked(judged(gCat, 2, gHk2, "pass", gTrunk0)) - w.settleAll() - setChecks(w, gCat, gHk2, "success", "success") - setChecks(w, gCtl, gHc, "success", "pending") - asked := len(w.ctl.checks) - composedPassAt(w, gHc, gHk2) - w.settleAll() - w.later(time30s) - w.settleAll() - own, composed := ownChecks(w, asked) - if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) { - t.Fatalf("own %v composed %v; wanted the member's own check asked again, once", own, composed) - } -} - -// An own ask before the Approve, answered, and then a merge refused for the member's own pending status at the same head: -// asked again (the one ask is per answer, not per head's life). -func TestAMergeRefusedForOwnPendingAfterAnAnsweredOwnAskAsksItAgain(t *testing.T) { - w := heldOnOwnPending(t) - w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) - setChecks(w, gCtl, gHc, "success", "success") - w.later(time30s) - w.settleAll() - if len(w.op.open()) != 1 { - t.Fatalf("the group is not asked once its statuses are green: %d", w.op.count()) - } - w.forge.refuseRepo = map[string]error{gCtl: ownPending} - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - w.h.PullMerged(merged(gCat, 2, gHk, gT1)) - asked := len(w.ctl.checks) - w.settleAll() - own, composed := ownChecks(w, asked) - if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) { - t.Fatalf("own %v composed %v; wanted the member's own check asked again after the refusal", own, composed) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1826_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1826_test.go deleted file mode 100644 index 04cf8bf6..00000000 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1826_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" - "testing" - - stdio "git.novox.be/novox/mesh-sdk/go" -) - -// Issue 1826 (novox/hq ADR 0502 clause 5): when mesh-config holds or drops a value for a pull request that is a -// delivery, its check, or its group's composed check, is asked again, and the transition says why. - -func configChanged(action, pull string) ConfigChange { - c := ConfigChange{Action: action, Pull: pull, Head: head, Key: "media-gid"} - c.Level.Node, c.Level.Module = "ace", "n8n" - return c -} - -// 1826/K4: a lone delivery that was ready is proposed again with its merge check pending, its check asked, and the -// transition says that a config value was held. -func TestAHeldValueAsksTheReadyDeliverysCheckAgain(t *testing.T) { - w := newWorld(t) - id := heldGreen(t, w, "novox/app", 7) - asked := len(w.ctl.checks) - w.h.ConfigChanged(configChanged("hold", "novox/app#7")) - if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+id { - t.Fatalf("asked %v; wanted its own check once", got) - } - d := w.delivery(id) - last := d.Transitions[len(d.Transitions)-1] - if d.State != Proposed || last.Event != EvRecheck || !strings.Contains(last.Why, "media-gid") || - !strings.Contains(last.Why, "held") || last.By != byOwner { - t.Errorf("state %s, last transition %+v", d.State, last) - } - if s := w.forge.statuses["novox/app@"+head+" "+ctxGate]; !strings.HasPrefix(s, "pending checking again") { - t.Errorf("the merge check says %q: the old verdict would still let it merge", s) - } -} - -// 1826/K4: a rejected delivery (the very case: the composed check failed on the missing value) is asked again on a -// hold; and a drop asks it again too. -func TestAHeldValueAsksARejectedDeliverysCheckAgainAndADropToo(t *testing.T) { - w := newWorld(t) - w.h.PullUpdated(pr("novox/app", 8, head, "feat/y", "src/a.go")) - w.h.Checked(verdict("novox/app", 8, head, "fail")) - w.settleAll() - id := IDOf("novox/app", head) - if w.state(id) != Rejected { - t.Fatalf("it is %s", w.state(id)) - } - asked := len(w.ctl.checks) - w.h.ConfigChanged(configChanged("hold", "novox/app#8")) - if w.state(id) != Proposed || len(w.ctl.checks) != asked+1 { - t.Fatalf("a hold left it %s with %v asked", w.state(id), w.ctl.checks[asked:]) - } - w.h.Checked(verdict("novox/app", 8, head, "pass")) - w.settleAll() - asked = len(w.ctl.checks) - w.h.ConfigChanged(configChanged("drop", "novox/app#8")) - d := w.delivery(id) - if len(w.ctl.checks) != asked+1 || !strings.Contains(d.Transitions[len(d.Transitions)-1].Why, "dropped") { - t.Errorf("a drop asked %v; last transition %+v", w.ctl.checks[asked:], d.Transitions[len(d.Transitions)-1]) - } -} - -// 1826/K4: a delivery whose check is on its way is asked again, its old ask replaced, and says why in its note. -func TestAHeldValueAsksTheCheckOfAProposedDeliveryAgain(t *testing.T) { - w := newWorld(t) - w.h.PullUpdated(pr("novox/app", 9, head, "feat/z", "src/a.go")) - w.settleAll() - id := IDOf("novox/app", head) - before := w.delivery(id).CheckAsk - asked := len(w.ctl.checks) - w.h.ConfigChanged(configChanged("hold", "novox/app#9")) - d := w.delivery(id) - if len(w.ctl.checks) != asked+1 || d.CheckAsk == nil || (before != nil && d.CheckAsk.ID == before.ID) || - !strings.Contains(d.CheckAsk.Why, "held") { - t.Errorf("asked %v; ask %+v (was %+v)", w.ctl.checks[asked:], d.CheckAsk, before) - } - if d.State != Proposed { - t.Errorf("it is %s", d.State) - } -} - -// 1826/K4: a hold for a member of a group asks the member's check again and then the group's composed check, which -// the group waits for. -func TestAHeldValueForAGroupMemberAsksTheComposedCheckAgain(t *testing.T) { - w := approvedGroup(t) - g := w.h.groups["feat/g"] - firstComposed := g.Check.Asked - asked := len(w.ctl.checks) - w.h.ConfigChanged(configChanged("hold", gCat+"#2")) - if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+IDOf(gCat, gHk) { - t.Fatalf("a hold asked %v; wanted the member's own check", got) - } - if g = w.h.groups["feat/g"]; g.Check.OutOfDate == "" || !strings.Contains(g.Check.OutOfDate, "rechecked") { - t.Fatalf("the composed check stands: %+v", g.Check) - } - w.later(60e9) - w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0)) - w.settleAll() - last := w.ctl.checks[len(w.ctl.checks)-1] - if !strings.HasPrefix(last, "feat/g:") || w.h.groups["feat/g"].Check.Asked == firstComposed { - t.Errorf("the composed check was not asked again: %v", w.ctl.checks[asked:]) - } -} - -// 1826/K4: what is not a hold or a drop for a delivery of this owner asks nothing: the merge's set, another action, -// another pull request, a pull request with no delivery, and a delivery already merged. -func TestOnlyAHoldOrADropForADeliveryAsksACheckAgain(t *testing.T) { - w := newWorld(t) - heldGreen(t, w, "novox/app", 7) - asked := len(w.ctl.checks) - for _, c := range []ConfigChange{configChanged("set", "novox/app#7"), configChanged("resolve", ""), - configChanged("hold", "novox/app#99"), configChanged("hold", "novox/other#7"), configChanged("hold", ""), - configChanged("hold", "garbage")} { - w.h.ConfigChanged(c) - } - if len(w.ctl.checks) != asked || w.state(IDOf("novox/app", head)) != Ready { - t.Errorf("asked %v; it is %s", w.ctl.checks[asked:], w.state(IDOf("novox/app", head))) - } -} - -// 1826/K4: the event is heard as mesh-config's config-changed, and it is no recheck verb. -func TestTheConfigChangedEventIsFollowed(t *testing.T) { - w := newWorld(t) - heldGreen(t, w, "novox/app", 7) - found := false - for _, f := range followed { - found = found || f == "mesh-config.config-changed" - } - if !found { - t.Fatalf("not followed: %v", followed) - } - raw, _ := json.Marshal(map[string]any{"action": "hold", "pull": "novox/app#7", "head": head, "key": "media-gid", - "level": map[string]any{"level": "module", "node": "ace", "module": "n8n"}}) - asked := len(w.ctl.checks) - if err := handle(w.h, stdio.Envelope{Key: "mesh-config.config-changed", Body: raw}); err != nil { - t.Fatal(err) - } - if len(w.ctl.checks) != asked+1 { - t.Errorf("the event asked %v", w.ctl.checks[asked:]) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue341_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue341_test.go index afdaea8a..5587111b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue341_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue341_test.go @@ -86,7 +86,7 @@ func TestATrunkMoveSendsAReadyDeliveryBackToProposed(t *testing.T) { w.h.Checked(late) w.settleAll() d = w.delivery(id66) - if d.State != Proposed || len(d.Transitions) != before || d.Check == nil || d.Check.ID != "check-late" { + if d.State != Proposed || len(d.Transitions) != before || d.Check == nil { t.Fatalf("a late verdict for an old trunk left it %s after %d transition(s), verdict %+v", d.State, len(d.Transitions)-before, d.Check) } @@ -163,156 +163,6 @@ func TestChecksAndShowNameTheHeadAndTheTrunkCommit(t *testing.T) { } } -// **A delivery group's composed check follows the same rule** (ADR 0323 §6, §7): a merge that is not one of its -// members into a member's trunk puts it out of date and the group reads checking; its own members merging in its -// order do not; and it runs again once a member is judged again. -func TestAGroupsComposedCheckGoesOutOfDateOnAMergeThatIsNotItsMember(t *testing.T) { - const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog" - const hc, hk = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1" - idc, idk := IDOf(ctl, hc), IDOf(cat, hk) - start := func(t *testing.T) *world { - w := newWorld(t) - w.h.PullUpdated(pr(ctl, 1, hc, "feat/g")) - w.h.PullUpdated(pr(cat, 2, hk, "feat/g")) - w.h.Checked(judged(ctl, 1, hc, "pass", trunk0)) - w.h.Checked(judged(cat, 2, hk, "pass", trunk0)) - w.settleAll() - g := w.h.groups["feat/g"] - if g == nil || g.Check == nil { - t.Fatalf("no composed check was asked: %+v", g) - } - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed", - Members: []struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Trunk string `json:"trunk,omitempty"` - }{{Owner: "novox", Repo: "mesh-controller", Commit: hc, Trunk: trunk0[:12]}, - {Owner: "novox", Repo: "mesh-catalog", Commit: hk, Trunk: trunk0[:12]}}}) - w.settleAll() - if state, _ := GroupState(g, w.h.membersOf(g)); state != "ready" { - t.Fatalf("the group composed green is %s", state) - } - if g.Check.Trunks[idc] != trunk0[:12] { - t.Fatalf("the composed check names its members' trunks %v", g.Check.Trunks) - } - return w - } - - t.Run("a merge that is not a member", func(t *testing.T) { - w := start(t) - asked := len(w.ctl.checks) - w.h.PullMerged(merged(ctl, 5, "5555555555555555", merge1)) - w.settleAll() - g := w.h.groups["feat/g"] - state, why := GroupState(g, w.h.membersOf(g)) - if state != "checking" || g.Check.OutOfDate == "" { - t.Fatalf("the group is %s (%s) after a merge that is not its member moved a member's trunk", state, why) - } - for _, head := range []string{ctl + "@" + hc, cat + "@" + hk} { - s := w.forge.statuses[head+" "+ctxGroup] - if !strings.HasPrefix(s, "pending ") || !strings.Contains(s, "out of date") { - t.Fatalf("mesh/delivery-group on %s says %q, not pending with the reason", head, s) - } - } - // The group's Approve stays; each member whose verdict went out of date has its check asked at once, against the - // trunk's head (ADR 0421 §3, issue 1047): only the member whose trunk moved, and the composed check is asked - // after it. - if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+idc { - t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got) - } - asked++ - // The controller's member judged again against the trunk's head: the composed check runs again. - w.later(time.Minute) - w.h.Checked(judged(ctl, 1, hc, "pass", merge1)) - w.settleAll() - if len(w.ctl.checks) == asked || !strings.HasPrefix(w.ctl.checks[len(w.ctl.checks)-1], "feat/g:") { - t.Fatalf("the composed check was not asked again once a member was judged again: %v", w.ctl.checks) - } - }) - - t.Run("its own member merging", func(t *testing.T) { - w := start(t) - mc := merged(ctl, 1, hc, merge1) - mc.Head = "feat/g" - w.h.PullMerged(mc) - w.settleAll() - g := w.h.groups["feat/g"] - if g.Check.OutOfDate != "" { - t.Fatalf("the group's own member merging put its composed check out of date: %s", g.Check.OutOfDate) - } - if s := w.state(idk); s != Ready { - t.Fatalf("the catalogue's member, in another repository, is %s", s) - } - }) -} - -// **After a member is rebased or pushed, the composed check runs again for the new heads** and the group reads -// checking, not held (ADR 0323 §6, issue 406's second symptom): its first member merged, closing the group, and then -// its second was rebased just before its own merge. -func TestARebasedMemberIsComposedAgainNotHeld(t *testing.T) { - const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog" - const hc, hk, hc2 = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1", "c2c2c2c2c2c2c2c2" - idc, idk, idc2 := IDOf(ctl, hc), IDOf(cat, hk), IDOf(ctl, hc2) - w := newWorld(t) - w.op.auto = false // the merges are heard as events below, as when the forge refused one - w.h.PullUpdated(pr(ctl, 1, hc, "feat/g")) - w.h.PullUpdated(pr(cat, 2, hk, "feat/g")) - w.h.Checked(judged(ctl, 1, hc, "pass", trunk0)) - w.h.Checked(judged(cat, 2, hk, "pass", trunk0)) - w.settleAll() - g := w.h.groups["feat/g"] - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}) - w.settleAll() - - // The catalogue's member merges first: the group is closed. - mk := merged(cat, 2, hk, merge1) - mk.Head = "feat/g" - w.h.PullMerged(mk) - w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", cat, merge1, true))) - w.settleAll() - if !g.Closed { - t.Fatal("the group is not closed once a member merged") - } - - // The controller's member is rebased just before its merge: a new head, with its own check. - w.h.PullUpdated(pr(ctl, 1, hc2, "feat/g")) - w.settleAll() - if d := w.delivery(idc2); d.Group != "feat/g" { - t.Fatalf("the rebased head did not take its place in its closed group: %q", d.Group) - } - if containsString(g.Members, idc) || !containsString(g.Members, idc2) || len(g.Members) != 2 { - t.Fatalf("the group's members are %v", g.Members) - } - if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" { - t.Fatalf("the group reads %s (%s), not checking", state, why) - } - w.h.Checked(judged(ctl, 1, hc2, "pass", trunk0)) - w.settleAll() - asked := w.ctl.checks[len(w.ctl.checks)-1] - if !strings.HasPrefix(asked, "feat/g:") || !strings.Contains(asked, idc2) || !strings.Contains(asked, idk) { - t.Fatalf("the composed check was not asked again for the new heads: %v", w.ctl.checks) - } - if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" { - t.Fatalf("the group reads %s (%s), not checking", state, why) - } - for _, id := range []string{idk, idc2} { - if d := w.delivery(id); d.State == Held { - t.Fatalf("%s is held for a person: %s", id, d.HeldWhy) - } - } - // Its verdict for the new heads: the rebased member merges and takes its turn. - w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"}) - mc := merged(ctl, 1, hc2, "e0e0e0e0e0e0e0e0") - mc.Head = "feat/g" - w.h.PullMerged(mc) - w.h.WalkMoved(w.ctl.put(aWalk("plan-ctl", ctl, "e0e0e0e0e0e0e0e0", true))) - w.settleAll() - if d := w.delivery(idc2); d.State == Held { - t.Fatalf("the rebased member, composed green for the new heads, is held: %s", d.HeldWhy) - } -} - // A head whose merge check the forge's holder put out of date is not a check asked and not answered: `stalled` does // not list it, however long it waits — rejected, or with no delivery here — until its check is asked (ADR 0323 §7). func TestAnOutOfDateCheckOnTheForgeIsNotSaidUnanswered(t *testing.T) { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue441_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue441_test.go index 1b6f0691..f607dc66 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue441_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue441_test.go @@ -199,26 +199,10 @@ func unansweredOf(w *world) []StalledLine { return out } -// **A said line clears when its delivery's state changes** (novox/hq issue 441): a recheck proposes it again, under -// the proposed state's own bound, and a merge publishes it, past the pull request. Both clear the line at once — +// **A said line clears when its delivery's state changes** (novox/hq issue 441): a merge publishes it, past the pull +// request. It clears the line at once — // before the forge is read again, which may lag — and stay cleared once it is read. func TestASaidLineClearsWhenItsDeliveryMovesOn(t *testing.T) { - t.Run("a recheck proposes it", func(t *testing.T) { - w, id := saidReady(t) - if _, err := w.h.Recheck(id, "its repo-check status was lost", "a person"); err != nil { - t.Fatal(err) - } - if s := w.state(id); s != Proposed { - t.Fatalf("a recheck left it %s", s) - } - if lines := unansweredOf(w); len(lines) != 0 { - t.Fatalf("a rechecked delivery, proposed again, is still said as unanswered: %+v", lines) - } - w.h.SeeOpenPulls() - if lines := unansweredOf(w); len(lines) != 0 { - t.Fatalf("a rechecked delivery is said again once the forge is read: %+v", lines) - } - }) t.Run("a merge publishes it", func(t *testing.T) { w, id := saidReady(t) w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/mesh-controller", merge, true))) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue783_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue783_test.go index c0dd6062..d3f5565d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue783_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue783_test.go @@ -84,7 +84,7 @@ func TestAMergeWithoutAnAnnouncementStartsOverAStoppedDuplicateToo(t *testing.T) walk := w.ctl.put(aWalk("plan-783b", catalog783, merge783b, true)) w.h.WalkMoved(walk) m := merged(catalog783, 316, head783b, merge783b) - m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"} + m.HeadChecks = map[string]string{"mesh/check": "success"} w.h.PullMerged(m) if d := w.delivery(id); d.State == Stopped || d.Number != 316 { t.Fatalf("#316's merge, heard with no announcement of its own, left it %s, Number %d; want started over for #316", diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/lostcheck.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/lostcheck.go deleted file mode 100644 index bef8e0d7..00000000 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/lostcheck.go +++ /dev/null @@ -1,211 +0,0 @@ -package main - -import ( - "fmt" - "strings" - "time" -) - -// A check the build queue lost (novox/hq issue 1274). -// -// A proposed delivery waits for a check it asked of the controller. When the build queue loses that ask — taken by a -// build agent that was replaced, or dropped with the queue's own state — nothing answers and nothing asks again: the -// delivery stays proposed until a new head is pushed, and a recheck from proposed was refused. The sweep now reads the -// controller's `queue` and asks again a check that no entry holds (neither waiting nor in flight; a dead one is not -// held) for longer than lostCheckBound. A recheck of a proposed delivery is taken on the same fact. Both are the -// table's EvLost, said in the delivery's transitions; neither asks while the queue holds an ask of the head, so no -// ask is made twice. A queue that cannot be read asks nothing: a guess would risk a duplicate. - -// lostReadEvery is how often the queue is read for one proposed delivery's ask: the read is one bus call, and a check -// the queue holds is looked at again no sooner. -const lostReadEvery = 2 * time.Minute - -// lostCheckBound is how long a proposed delivery's asked check may go with no entry in the build queue before it is -// asked again: the repository's last measured check time (ADR 0505 §4, lastCheckTime), at least UncheckedAfter (ten -// minutes, the bound a check never asked is said at). Under the lock. -func (h *Holder) lostCheckBound(repository string) time.Duration { - return max(UncheckedAfter, h.lastCheckTime(repository)) -} - -// awaitedSince is when a proposed delivery's check was asked, when it waits for one this owner asked: no verdict yet, -// or a verdict out of date with its check against the trunk's head asked. False for a delivery waiting for none. -func awaitedSince(d *Delivery) (time.Time, bool) { - if d.State != Proposed || d.MergedAs != "" || d.Number == 0 || d.judgedByCheck() { - return time.Time{}, false // a head on mesh/check asks the controller nothing (issue 1302) - } - switch { - case d.Check == nil && askedCheck(d): - return d.CheckAsk.At, true - case d.Check != nil && d.outOfDate() != "" && d.AskedAgain != nil: - return *d.AskedAgain, true - } - return time.Time{}, false -} - -// heldBy is the queue's entry that holds a check of the delivery's head: its own ask by id, or any check of its -// repository at its head — a group's composed check included, which a second ask would only repeat. -func heldBy(d *Delivery, asks []QueuedAsk) *QueuedAsk { - for i, a := range asks { - if !a.Holds() { - continue - } - if d.CheckAsk != nil && d.CheckAsk.ID != "" && a.ID == d.CheckAsk.ID { - return &asks[i] - } - if a.Kind == "check" && a.Commit != "" && sameCommit(a.Commit, d.Commit) && - strings.Contains(strings.ToLower(a.Repository), strings.ToLower(d.Repository)) { - return &asks[i] - } - } - return nil -} - -func (a QueuedAsk) words() string { - // The controller's `queue` says the state "in flight", its table's word. - state := strings.ReplaceAll(a.State, "-", " ") - if state == "in flight" && a.Machine != "" { - return a.ID + " is in flight on " + a.Machine - } - return a.ID + " is " + state + " in the build queue" -} - -// lostChecks are the asks again of proposed deliveries whose asked check the build queue holds no entry of, past the -// bound, at each look. Under the lock; the queue is read, and the asks made, outside it. -func (h *Holder) lostChecks(now time.Time) func() { - var ids []string - for id, d := range h.deliveries { - since, ok := awaitedSince(d) - if !ok { - delete(h.lostRead, id) - continue - } - due := since.Add(h.lostCheckBound(d.Repository)) - if now.Before(due) { - h.lookAt(due, "a proposed delivery's asked check, read in the build queue once its bound passed") - continue - } - if read, was := h.lostRead[id]; was && now.Sub(read) < lostReadEvery { - h.lookAt(read.Add(lostReadEvery), "a proposed delivery's asked check, looked for in the build queue again") - continue - } - h.lostRead[id] = now - h.lookAt(now.Add(lostReadEvery), "a proposed delivery's asked check, looked for in the build queue again") - ids = append(ids, id) - } - if len(ids) == 0 { - return nil - } - return func() { - asks, err := h.Controller.Queue() - if err != nil { - h.Logf("[mesh-delivery] the build queue cannot be read, so no check is asked again for a lost ask: %v", err) - return - } - for _, id := range ids { - h.mu.Lock() - d := h.deliveries[id] - since, ok := time.Time{}, false - if d != nil { - since, ok = awaitedSince(d) - } - var held *QueuedAsk - if ok { - held = heldBy(d, asks) - } - var why string - if ok && held == nil { - what := "its check against the trunk's head" - if d.Check == nil { - what = "its check " + d.CheckAsk.ID - } - why = fmt.Sprintf("%s, asked %s ago, is held by no entry of the build queue (neither waiting nor in flight): "+ - "lost, asked again", what, h.Now().Sub(since).Round(time.Second)) - } - h.mu.Unlock() - if why == "" { - continue - } - if _, err := h.askLost(id, byOwner, why, asks); err != nil { - h.Logf("[mesh-delivery] %s: its lost check could not be asked again: %v", id, err) - h.again("a proposed delivery's lost check that could not be asked again") - } - } - } -} - -// recheckProposed is `recheck` of a proposed delivery (issue 1274): taken, with why, when no check of its head is -// waiting or in flight in the build queue, and refused naming the one that is. A queue that cannot be read refuses it. -func (h *Holder) recheckProposed(id, why, by string) (string, error) { - if why == "" { - return "", fmt.Errorf("a recheck says why") - } - asks, err := h.Controller.Queue() - if err != nil { - return "", fmt.Errorf("%s is not rechecked: the build queue cannot be read (%v), so whether its check is still held is not known", id, err) - } - h.mu.Lock() - d := h.deliveries[id] - var held *QueuedAsk - if d != nil { - held = heldBy(d, asks) - } - h.mu.Unlock() - if held != nil { - return "", fmt.Errorf("%s is not rechecked: its check is still held, %s", id, held.words()) - } - return h.askLost(id, by, why, asks) -} - -// askLost asks a proposed delivery's check again, the table's EvLost: tried first on a copy, so an ask the controller -// does not take spends nothing. Its group's composed check goes out of date, and is asked once the member answers. -func (h *Holder) askLost(id, by, why string, asks []QueuedAsk) (string, error) { - h.mu.Lock() - d := h.deliveries[id] - if d == nil { - h.mu.Unlock() - return "", fmt.Errorf("no delivery %q", id) - } - if held := heldBy(d, asks); held != nil { - h.mu.Unlock() - return "", fmt.Errorf("%s is not asked again: its check is still held, %s", id, held.words()) - } - probe := *d - probe.Transitions = nil - if _, err := Apply(&probe, EvLost, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil { - h.mu.Unlock() - return "", err - } - m := MemberOf(d) - if d.Check != nil && d.outOfDate() != "" && d.TrunkNow != "" { - m.BaseSHA = d.TrunkNow - } - h.mu.Unlock() - asked, err := h.Controller.Check("", []Member{m}) - if err != nil { - return "", fmt.Errorf("%s's check could not be asked again yet: %w", id, err) - } - h.mu.Lock() - defer h.mu.Unlock() - d = h.deliveries[id] - if d == nil || d.State != Proposed { - return "", fmt.Errorf("%s moved while its check was asked again", id) - } - now := h.Now() - t, err := Apply(d, EvLost, true, Facts{Now: now, Why: why + " — asked as " + asked, By: by}) - if err != nil { - return "", err - } - d.CheckAsk = &CheckAsk{ID: asked, At: now, By: by, Why: why} - if d.Check != nil { - d.AskedAgain, d.FreshWanted = &now, false - } - delete(h.askTried, id) - if g := h.groups[d.Group]; g != nil && g.Check != nil { - g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s's check was lost in the build queue and asked again", d.ID) - g.Check.OutOfDateAt = now - g.Updated = now - h.keepGroup(g) - } - h.moved(d, []Transition{t}) - return fmt.Sprintf("%s: its check was held by no entry of the build queue and is asked again as %s (%s)", id, asked, why), nil -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index 5f12ef4d..2d70ff97 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -30,10 +30,10 @@ type listening struct { func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() } func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now } -// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks. +// The events this owner follows: the forge's pull requests, and the controller's walks. // The forge's status events say that a check answered (events.go). var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed", - "gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".checked", ControllerSeat + ".plan-moved", + "gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".plan-moved", forgePattern, ConfigChangedEvent} // forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one. @@ -219,14 +219,7 @@ func take(h *Holder, e stdio.Envelope) error { return nil } h.CommitStatus(s) - case ControllerSeat + ".checked": - var c CheckedEvent - if err := json.Unmarshal(e.Body, &c); err != nil { - h.Refuse(fmt.Errorf("a verdict is not readable: %v", err)) - return nil - } - h.Checked(c) - h.KeepVerdict(c) + h.KeepCheckVerdict(s) case ConfigChangedEvent: var c ConfigChange if err := json.Unmarshal(e.Body, &c); err != nil { @@ -354,9 +347,8 @@ func tools(h *Holder, l *listening) []stdio.Tool { }}, {Name: seat + "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each of the commit's " + - "statuses (mesh/check, mesh/delivery, …; hq's mesh/repo-check) with its state, description and when it " + - "was set; the merge check's full verdict as the controller said it — each layer's summary, the machine " + - "that ran it, when, its build id and its report; and whether the branch's protection would let it " + + "statuses (mesh/check, mesh/delivery, …) with its state, description and when it " + + "was set; the merge check's verdict as the checker's mesh/check line says it, and when it was heard; and whether the branch's protection would let it " + "merge, every required status being success.", Input: map[string]any{"type": "object", "properties": map[string]any{"repository": str("owner/repository"), "number": str("a pull request's number: its head is read"), @@ -389,8 +381,8 @@ func tools(h *Holder, l *listening) []stdio.Tool { return h.Stalled(), nil }}, {Name: seat + "recheck", - Description: "Check a rejected or ready delivery again: its merge check statuses put back to pending on the forge, " + - "so nothing merges on the old verdict; proposed again, and its check asked. With why.", + Description: "Refused for every delivery: the checker checks a head again on every move of the head, its trunk or its " + + "group, and nothing is asked of anyone here (novox/hq ADR 0447, issue 1318). Kept so a caller is told where to read.", Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id"), "why": str("why, kept with the transition")}, "required": []string{"id", "why"}}, Run: func(a map[string]any) (any, error) { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index 30f76f7c..9dcbb55d 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -72,6 +72,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { if len(m.Claims[0].Serves) != 13 { t.Fatalf("serves %d verbs, wanted 13: %v", len(m.Claims[0].Serves), m.Claims[0].Serves) } + for _, never := range []string{"mesh-controller.checked", "seat:mesh-controller.delivery-check", "seat:mesh-controller.queue"} { + if slices.Contains(m.Consumes, never) || slices.Contains(m.Invokes, never) { + t.Errorf("the manifest still names %s (issue 1318)", never) + } + } if !reflect.DeepEqual(m.Consumes, followed) || !slices.Contains(m.Consumes, "mesh-delivery-forge-bridge.forge.**") { t.Fatalf("consumes %v, follows %v (the bridge's forge messages among them)", m.Consumes, followed) } @@ -89,11 +94,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { "mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts", "mesh-controller.assignments", "node-engine.have"}) { t.Fatalf("reads %v", m.Reads) } - // It calls exactly the controller's seven verbs (ADR 0450 §7; queue since issue 1274, forget-checks no more since issue 1318) and reads the forge with + // It calls exactly the controller's five verbs (ADR 0450 §7; no delivery-check, no queue and no forget-checks since issue 1318, record D5) and reads the forge with // three tools; the forge's // writes are the bridge's (ADR 0437 §8). It uses no operator-channel: the teller asks (ADR 0437 §6). want := []string{} - for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "queue", "plans"} { + for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "plans"} { want = append(want, "seat:"+ControllerSeat+"."+v) } want = append(want, "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls") 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 35e7ba14..7822287a 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go @@ -109,10 +109,9 @@ func verdictOfCheck(state, desc string, now time.Time) *Verdict { Status: ctxCheck} } -// judgedByCheck is whether the delivery's base requires mesh/check, as the forge said it, or its verdict is mesh/check's. -func (d *Delivery) judgedByCheck() bool { - return slices.Contains(d.Required, ctxCheck) || (d.Check != nil && d.Check.Status == ctxCheck) -} +// judgedByCheck is whether the checker judges the delivery: always. Every base requires mesh/check since hq's did (ADR 0527 +// §2), and the controller judges no pull request any more (ADR 0524 §5), so nothing is asked of it (issue 1318). +func (d *Delivery) judgedByCheck() bool { return true } // noteRequired keeps what the forge said the delivery's base requires. Under the lock. func (h *Holder) noteRequired(d *Delivery, required []string) { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_test.go index 6bfb3c26..268a2493 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "strings" "testing" - "time" sdk "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -229,17 +228,3 @@ func TestTheCheckLineGivesHeadAndTrunk(t *testing.T) { t.Fatalf("a line that names no commits gave %q %q", head, trunk) } } - -// 1302 with issue 1274's lost checks: a head on mesh/check never awaits a controller's check, so the build queue is not -// read for it and nothing is asked again. -func TestAHeadOnMeshCheckAwaitsNoControllersCheck(t *testing.T) { - at := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) - d := &Delivery{State: Proposed, Number: 1, CheckAsk: &CheckAsk{ID: "check-1", At: at}} - if _, ok := awaitedSince(d); !ok { - t.Fatal("the controller's asked check is not awaited") - } - d.Required = []string{ctxCheck} - if _, ok := awaitedSince(d); ok { - t.Fatal("a head on mesh/check awaits a controller's check") - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go index b416fa9c..74cbd5b9 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ports.go @@ -49,8 +49,6 @@ func MemberOf(d *Delivery) Member { type Controller interface { // Plan is the delivery plan of a diffset, kept nowhere. Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error) - // Check asks a group's composed check, or — with no group and one member — that head's own check again. - Check(group string, members []Member) (string, error) // Deliver lets a waiting walk start. Deliver(walk, why string) error // BackupsAgain asks the controller to take a held walk's failed backups again, and to go on when they pass @@ -64,24 +62,8 @@ type Controller interface { StopWithout(walk, why, by string, without []string) (StopAnswer, error) // Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record. Walks(walk string) (bool, []Walk, error) - // Queue is every ask the build queue holds, waiting or in flight (the controller's `queue`, novox/hq issue 455): what - // tells a check still on its way from one the queue lost (issue 1274). A dead ask is not held. - Queue() ([]QueuedAsk, error) } -// QueuedAsk is one ask of the build queue as the controller's `queue` answers it as data. -type QueuedAsk struct { - ID string `json:"id"` - State string `json:"state"` // waiting, in flight or dead - Kind string `json:"kind"` // check or delivery - Repository string `json:"repository"` - Commit string `json:"commit,omitempty"` - Machine string `json:"machine,omitempty"` -} - -// Holds is whether the ask is still the queue's to run: waiting or in flight, never dead. -func (q QueuedAsk) Holds() bool { return q.State != "dead" } - // Forge is what the forge's holder does for a delivery. type Forge interface { // Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch @@ -240,43 +222,6 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r return a.Plan, nil } -func (c seatController) Check(group string, members []Member) (string, error) { - args := map[string]any{"members": string(mustJSON(members))} - if group != "" { - args["group"] = group - } - raw, err := c.verb("delivery-check", args) - if err != nil { - return "", err - } - var a struct { - Asked string `json:"asked"` - Rechecked string `json:"rechecked"` - } - _ = json.Unmarshal(raw, &a) - if a.Asked != "" { - return a.Asked, nil - } - if a.Rechecked != "" { - return a.Rechecked, nil - } - return "", fmt.Errorf("the controller's delivery-check answered no check id (%s): it is not taken as asked", clip(string(raw), 200)) -} - -func (c seatController) Queue() ([]QueuedAsk, error) { - raw, err := c.verb("queue", map[string]any{}) - if err != nil { - return nil, err - } - var a struct { - Asks []QueuedAsk `json:"asks"` - } - if err := json.Unmarshal(raw, &a); err != nil { - return nil, fmt.Errorf("the controller's queue is not readable: %w", err) - } - return a.Asks, nil -} - func (c seatController) Deliver(walk, why string) error { _, err := c.verb("deliver", map[string]any{"plan": walk, "why": why}) return err diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go index 81bdaa67..4b2b7701 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey.go @@ -51,27 +51,69 @@ func (h *Holder) KeepPull(p PullEvent) { if err != nil { h.Logf("[mesh-delivery] the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err) h.again("a pull request's key that could not be written") - } -} - -// KeepVerdict writes a verdict into the key of its pull request, when the key holds that head and trunk commit. -func (h *Holder) KeepVerdict(c CheckedEvent) { - if h.Pulls == nil || c.Group != "" || c.Number == 0 || c.Commit == "" || c.Trunk == "" { return } - verdict := c.Verdict + // The head's mesh/check may have been heard before this key held the head: the two events are taken on goroutines of + // their own, and KeepCheckVerdict then found no head to write a verdict to. The verdict the delivery holds for this head + // and trunk is written now; read after the write above landed, it cannot miss one taken before that write. + verdict := h.checkVerdictOf(p.repository(), p.HeadSHA, p.BaseSHA) if verdict == "" { return } - err := h.Pulls.Change(pullKeyName(c.Owner, c.Repo, c.Number), func(k PullKey) (PullKey, bool) { - if !sameCommit(k.Head, c.Commit) || !sameCommit(k.Trunk, c.Trunk) || k.Verdict == verdict { + err = h.Pulls.Change(pullKeyName(p.Owner, p.Repo, p.Number), func(k PullKey) (PullKey, bool) { + if !sameCommit(k.Head, p.HeadSHA) || !sameCommit(k.Trunk, p.BaseSHA) || k.Verdict != "" { return k, false } k.Verdict = verdict return k, true }) if err != nil { - h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", c.Owner, c.Repo, c.Number, err) + h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err) + h.again("a pull request's key that could not be written") + } +} + +// checkVerdictOf is the verdict of the mesh/check the delivery of a head holds, when it judged this head on this trunk +// commit; empty otherwise. Outside the lock. +func (h *Holder) checkVerdictOf(repository, head, trunk string) string { + h.mu.Lock() + defer h.mu.Unlock() + d := h.deliveries[IDOf(strings.ToLower(repository), head)] + if d == nil || d.Check == nil || d.Check.Status != ctxCheck || !sameCommit(d.Check.Head, head) || !sameCommit(d.Check.Trunk, trunk) { + return "" + } + return d.Check.Gate +} + +// KeepCheckVerdict writes the verdict of a head's mesh/check into the key of its pull request, when the key holds that head +// and the trunk commit the check line names (issue 1302; the controller's verdict wrote it before issue 1318). Outside the lock. +func (h *Holder) KeepCheckVerdict(e ForgeStatusEvent) { + if h.Pulls == nil || e.Context != ctxCheck || e.Description == "" || e.SHA == "" { + return + } + verdict := map[string]string{"success": "pass", "warning": "warning", "failure": "fail", "error": "error"}[e.State] + _, trunk := checkLine(e.Description) + if verdict == "" || trunk == "" { + return // pending is no verdict; a line naming no trunk commit is judged against none + } + h.mu.Lock() + number := 0 + if d := h.deliveries[IDOf(strings.ToLower(e.Owner+"/"+e.Repo), e.SHA)]; d != nil { + number = d.Number + } + h.mu.Unlock() + if number == 0 { + return + } + err := h.Pulls.Change(pullKeyName(e.Owner, e.Repo, number), func(k PullKey) (PullKey, bool) { + if !sameCommit(k.Head, e.SHA) || !sameCommit(k.Trunk, trunk) || k.Verdict == verdict { + return k, false + } + k.Verdict = verdict + return k, true + }) + if err != nil { + h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", e.Owner, e.Repo, number, err) h.again("a pull request's key that could not be written") } } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey_test.go index 05d44623..a24a2882 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/pullkey_test.go @@ -71,33 +71,11 @@ func TestAnAnnouncedHeadWritesTheKeyAndTheSameAnnouncementAgainWritesNothing(t * } } -func TestAVerdictForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) { - w := newWorld(t) - pulls := newFakePulls() - w.h.Pulls = pulls - w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1")) - old := judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1") - w.h.KeepVerdict(old) - if pulls.get(appKey).Verdict != "" { - t.Fatal("a verdict for another head was written") - } - other := judgedAt("novox/app", 7, readyHead, "pass", "t9t9t9t9t9t9") - w.h.KeepVerdict(other) - if pulls.get(appKey).Verdict != "" { - t.Fatal("a verdict for another trunk commit was written") - } - w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) - if got := pulls.get(appKey); got.Verdict != "pass" { - t.Fatalf("the key holds %+v", got) - } -} - func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T) { w := newWorld(t) pulls := newFakePulls() w.h.Pulls = pulls w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1")) - w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) w.h.KeepPull(announcedAt(readyHead, "t2t2t2t2t2t2")) if got := pulls.get(appKey); got.Trunk != "t2t2t2t2t2t2" || got.Verdict != "" { t.Fatalf("the check asked again against a new trunk left %+v", got) @@ -108,6 +86,34 @@ func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T) } } +func TestAMeshCheckForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) { + w := newWorld(t) + pulls := newFakePulls() + w.h.Pulls = pulls + w.h.PullUpdated(announcedAt(readyHead, "a1a1a1a1a1a1")) + w.h.KeepPull(announcedAt(readyHead, "a1a1a1a1a1a1")) + status := func(head, trunk, state string) ForgeStatusEvent { + return ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: head, Context: ctxCheck, State: state, + Description: "pass " + head[:12] + " on " + trunk + " | unit 3 run"} + } + w.h.KeepCheckVerdict(status(pushed, "a1a1a1a1a1a1", "success")) + if pulls.get(appKey).Verdict != "" { + t.Fatal("a verdict for another head was written") + } + w.h.KeepCheckVerdict(status(readyHead, "b2b2b2b2b2b2", "success")) + if pulls.get(appKey).Verdict != "" { + t.Fatal("a verdict for another trunk commit was written") + } + w.h.KeepCheckVerdict(status(readyHead, "a1a1a1a1a1a1", "pending")) + if pulls.get(appKey).Verdict != "" { + t.Fatal("a pending check was written as a verdict") + } + w.h.KeepCheckVerdict(status(readyHead, "a1a1a1a1a1a1", "success")) + if got := pulls.get(appKey); got.Verdict != "pass" { + t.Fatalf("the key holds %+v", got) + } +} + func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T) { w := newWorld(t) pulls := newFakePulls() @@ -120,39 +126,6 @@ func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T) } } -// A check asked for a head whose Approve is asked is on the ready stream (ADR 0406 §3, ADR 0421 §10); one for a head -// with no Approve is asked as before. -func TestAPullRequestWithItsApproveAskedHasItsCheckAskedOnTheReadyStream(t *testing.T) { - w := newWorld(t) - w.op.auto = false - w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1")) - w.settleAll() - w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2")) - w.settleAll() - if len(w.ctl.checkReady) == 0 || !w.ctl.checkReady[0] { - t.Fatalf("the check of a pull request whose Approve is asked was asked with ready %v", w.ctl.checkReady) - } - // One with no Approve asked is asked as before. - w2 := newWorld(t) - w2.h.PullUpdated(pr("novox/app", 8, pushed, "feat/y", "src/a.go")) - w2.settleAll() - if len(w2.ctl.checkReady) == 0 || w2.ctl.checkReady[0] { - t.Fatalf("the check of a pull request with no Approve asked was asked with ready %v", w2.ctl.checkReady) - } -} - -func TestATrunkLessVerdictWritesNothingToTheKey(t *testing.T) { - w := newWorld(t) - pulls := newFakePulls() - w.h.Pulls = pulls - w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1")) - w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "")) - if got := pulls.get(appKey); got.Verdict != "" { - t.Fatalf("a verdict naming no trunk commit was written: %+v", got) - } -} - // The forge's catch-up after a restart can announce a merge again past the bus's de-duplication window (issue 975): // heard again, it changes nothing, and moves no trunk back. func TestTheSameMergeHeardAgainAfterALaterMergeMovesNoTrunkBack(t *testing.T) { @@ -172,3 +145,21 @@ func TestTheSameMergeHeardAgainAfterALaterMergeMovesNoTrunkBack(t *testing.T) { t.Fatalf("the trunk moved from %q to %q on a merge heard again; wanted it to stay m9m9m9m9m9m9", before.TrunkNow, after.TrunkNow) } } + +// The forge's mesh/check event handled wholly between the announcement's PullUpdated and its KeepPull (the two events are +// taken on goroutines of their own): the verdict must still reach the pull request's key. +func TestAVerdictHeardBeforeTheKeyIsWrittenReachesTheKey(t *testing.T) { + w := newWorld(t) + pulls := newFakePulls() + w.h.Pulls = pulls + p := announcedAt(readyHead, "a1a1a1a1a1a1") + w.h.PullUpdated(p) + s := ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: ctxCheck, State: "success", + Description: "pass " + readyHead[:12] + " on a1a1a1a1a1a1 | unit 3 run"} + w.h.CommitStatus(s) + w.h.KeepCheckVerdict(s) + w.h.KeepPull(p) + if got := pulls.get(appKey); got.Head != readyHead || got.Verdict != "pass" { + t.Fatalf("the key holds %+v, want the head with the verdict pass", got) + } +} 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 bda03ce7..774843ea 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -23,9 +23,9 @@ import ( // or second Approve); it is asked once for a head, and once for a group's heads together; // - it merges through the forge's merge tool (gitea_merge_pull_request), the one path every merge takes, at the head // the Approve names, handing the tool the heads the Approve covers (ADR 0421 §9); -// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is asked -// again by this owner at once (askFresh), and an Approve already asked stays through a trunk move (ADR 0421 §3): the -// merge is asked again on the passing verdict, with no second Approve; +// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged +// again by the checker on its trunk's move (ADR 0447 §4), and an Approve already asked stays through a trunk move +// (ADR 0421 §3): the merge is asked again on the passing verdict, with no second Approve; // - a group member only once the group's composed check passed for its members' heads as they are; // - a push ends the Approve and says so once on the pull request (ADR 0421 §6); the new head is asked on its own; // - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the @@ -175,9 +175,6 @@ func (h *Holder) readyAsks(now time.Time) []func() { if a := h.sayAfterLine(d); a != nil { asks = append(asks, a) } - if a := h.askFresh(d, now); a != nil { - asks = append(asks, a) - } r := d.Ready if r == nil || r.Merged != nil || r.Declined != nil { continue @@ -294,50 +291,6 @@ func (h *Holder) sayAfterLine(d *Delivery) func() { "the order of a delivery comes from the module graph alone, and nothing asks for the line (novox/hq ADR 0419).") } -// askFresh is the ask of a head's check against its trunk's head, when its passing verdict is out of date and either -// arrived so, or has an Approve asked or given for it (novox/hq ADR 0421 §3, issue 1022; issue 1047 for a group's -// member): the Approve stays through a trunk move, and the merge is asked again on the verdict this brings, with no -// second Approve. Nil when nothing needs asking. Under the lock; the ask runs outside it. -func (h *Holder) askFresh(d *Delivery, now time.Time) func() { - if d.State != Proposed || d.outOfDate() == "" || d.AskedAgain != nil || !d.Check.Passes() || d.Base != MergeBranch || - d.Number == 0 || d.TrunkNow == "" || d.judgedByCheck() { - return nil // a head on mesh/check is the checker's to judge again on its trunk's move (ADR 0447 §4) - } - // A group's member too (issue 1047, design 47: a move of the trunk asks every check again): the group's one Approve is - // asked after its composed check, so a member ready before that has no Approve to carry; its check is asked at once - // all the same, and the composed check follows on the new verdict. The rest of ADR 0395 §1 stays with issue 888. - if !d.FreshWanted && h.covering(d) == nil && h.groups[d.Group] == nil { - return nil - } - if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute { - h.lookAt(tried.Add(time.Minute), "a check against the trunk's head that could not be asked") - return nil - } - h.askTried[d.ID] = now - h.lookAt(now.Add(time.Minute), "a check against the trunk's head that could not be asked") - id, m := d.ID, MemberOf(d) - m.BaseSHA = d.TrunkNow - return func() { - asked, err := h.Controller.Check("", []Member{m}) - h.mu.Lock() - defer h.mu.Unlock() - if err != nil { - h.Logf("[mesh-delivery] %s's check against the trunk's head cannot be asked yet: %v", id, err) - return - } - delete(h.askTried, id) - d := h.deliveries[id] - if d == nil || d.State != Proposed || d.outOfDate() == "" { - return - } - t := h.Now() - d.AskedAgain, d.FreshWanted = &t, false - h.Logf("[mesh-delivery] %s: its verdict is out of date; its check against the trunk's head %s asked as %s", id, - shortOf(d.TrunkNow, 12), asked) - h.keep(d) - } -} - // mergeAgainNow lets an approved delivery's merge be asked at once: something that decides whether it may merge happened (a // verdict, an Approve, a group's composed check), so the wait after a refusal does not hold it. Under the lock. func (h *Holder) mergeAgainNow(d *Delivery) { 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 0f09e359..4b1f0f03 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 @@ -186,9 +186,6 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing. w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1")) w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3")) w.settleAll() - if len(w.forge.merges) != 0 || w.op.count() != 0 { - t.Fatalf("a member was merged or asked before its group's composed check passed: %v, %d", w.forge.merges, w.op.count()) - } w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"}) w.settleAll() if w.op.count() != 1 || len(w.forge.merges) != 0 { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go deleted file mode 100644 index f8bc9be4..00000000 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/recheck_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" -) - -// A recheck puts the merge check's statuses back to pending before it asks the check: the forge keeps the -// newest status of each context on the head, and a recheck asks of that same head, so the old verdict would -// stand — and branch protection would let the pull request merge on it — until the fresh one lands. - -// heldGreen is a head whose merge check passed, as the forge's holder set it from the verdict. -func heldGreen(t *testing.T, w *world, repo string, number int) string { - t.Helper() - w.h.PullUpdated(pr(repo, number, head, "feat/x", "src/a.go")) - owner, name, _ := strings.Cut(repo, "/") - _ = w.forge.Status(owner, name, head, ctxGate, "success", "pass: the gate said pass", "") - _ = w.forge.Status(owner, name, head, ctxRepo, "success", "pass: merge-check.sh passed", "") - w.forge.required = []string{ctxGate, ctxRepo} - w.h.Checked(verdict(repo, number, head, "pass")) - w.settleAll() - id := IDOf(repo, head) - if s := w.state(id); s != Ready { - t.Fatalf("a passing verdict left it %s", s) - } - return id -} - -func TestARecheckPutsTheMergeCheckBackToPendingBeforeItAsks(t *testing.T) { - w := newWorld(t) - id := heldGreen(t, w, "novox/app", 7) - at := "novox/app@" + head + " " - var whenAsked map[string]string - w.ctl.onCheck = func() { - w.forge.mu.Lock() - defer w.forge.mu.Unlock() - whenAsked = map[string]string{ctxGate: w.forge.statuses[at+ctxGate], ctxRepo: w.forge.statuses[at+ctxRepo]} - } - if _, err := w.h.Recheck(id, "main moved on", "a person"); err != nil { - t.Fatal(err) - } - if len(w.ctl.checks) != 1 { - t.Fatalf("its check was asked %v", w.ctl.checks) - } - for _, c := range []string{ctxGate, ctxRepo} { - if !strings.HasPrefix(whenAsked[c], "pending checking again: main moved on") { - t.Fatalf("as its check was asked, the forge held %s %q: the old verdict still lets it merge", c, whenAsked[c]) - } - } - checks, err := w.forge.Statuses("novox", "app", 0, head, "main") - if err != nil { - t.Fatal(err) - } - if checks.Merge.Mergeable == nil || *checks.Merge.Mergeable { - t.Fatalf("rechecked, the forge would still merge it: %+v", checks.Merge) - } - if s := w.state(id); s != Proposed { - t.Fatalf("rechecked, it is %s", s) - } -} - -// Only the statuses the head holds are put back: a verdict that says no repository check would leave a -// pending one standing for ever. -func TestARecheckLeavesAStatusTheHeadNeverHadAlone(t *testing.T) { - w := newWorld(t) - w.h.PullUpdated(pr("novox/app", 8, head, "feat/y", "src/a.go")) - _ = w.forge.Status("novox", "app", head, ctxGate, "failure", "fail: the gate said fail", "") - w.h.Checked(verdict("novox/app", 8, head, "fail")) - w.settleAll() - id := IDOf("novox/app", head) - if _, err := w.h.Recheck(id, "flaky", "a person"); err != nil { - t.Fatal(err) - } - if s := w.forge.statuses["novox/app@"+head+" "+ctxGate]; !strings.HasPrefix(s, "pending checking again: flaky") { - t.Fatalf("the gate is %q", s) - } - if s, set := w.forge.statuses["novox/app@"+head+" "+ctxRepo]; set { - t.Fatalf("a repository check the head never had was set: %q", s) - } -} - -// A bus that cannot take the pending statuses refuses the recheck whole: nothing is asked on a stale verdict, and the -// delivery stays where it stood, for the person to ask again. -func TestARecheckTheBusCannotTakeIsRefused(t *testing.T) { - w := newWorld(t) - id := heldGreen(t, w, "novox/app", 9) - w.owed.down = true - if _, err := w.h.Recheck(id, "main moved on", "a person"); err == nil { - t.Fatal("a recheck whose statuses could not be put back was taken") - } - if len(w.ctl.checks) != 0 { - t.Fatalf("its check was asked on a stale verdict: %v", w.ctl.checks) - } - if s := w.state(id); s != Ready { - t.Fatalf("refused, it moved to %s", s) - } -} - -// novox/hq issue 867, ADR 0323 §6: "the composed check runs again when a member is rechecked". A group whose composed -// check failed stayed rejected after a member was rechecked and passed, because the kept verdict was for the same heads -// and nothing marked it out of date. -func TestAMembersRecheckAsksTheGroupsComposedCheckAgain(t *testing.T) { - w := newWorld(t) - w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z")) - w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z")) - w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass")) - w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) - w.settleAll() - groupAsks := func() (n int) { - for _, c := range w.ctl.checks { - if strings.HasPrefix(c, "feat/z:") { - n++ - } - } - return - } - if groupAsks() != 1 { - t.Fatalf("the group was asked %v", w.ctl.checks) - } - w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"}) - w.settleAll() - if state, _ := GroupState(w.h.groups["feat/z"], w.h.membersOf(w.h.groups["feat/z"])); state != "rejected" { - t.Fatalf("the group is %s", state) - } - if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil { - t.Fatal(err) - } - // The member's own check passes again, at the same head, a while later. - w.later(5 * time.Minute) - w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) - w.settleAll() - if groupAsks() != 2 { - t.Fatalf("a member's recheck did not ask the group again: %v", w.ctl.checks) - } - if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.Asked == "check-1" { - t.Fatalf("the old verdict still stands: %+v", g.Check) - } -} - -// A recheck made while the group's composed check is still running is not lost: the old run's answer lands, and the -// group is still asked again once the member is ready. -func TestARecheckWhileTheComposedCheckRunsAsksTheGroupAgain(t *testing.T) { - w := newWorld(t) - w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z")) - w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z")) - w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass")) - w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) - w.settleAll() // the composed check check-1 is asked and running: no verdict yet - if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil { - t.Fatal(err) - } - // The old run answers. - w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"}) - w.later(5 * time.Minute) - w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass")) - w.settleAll() - n := 0 - for _, c := range w.ctl.checks { - if strings.HasPrefix(c, "feat/z:") { - n++ - } - } - if n != 2 { - t.Fatalf("the group was not asked again after a recheck made while its check ran: %v", w.ctl.checks) - } - if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.OutOfDate != "" { - t.Fatalf("the old run's answer stands: %+v", g.Check) - } - // The old run answering again is history. - w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "late"}) - if g := w.h.groups["feat/z"]; g.Check.Verdict != "" { - t.Fatalf("a late answer of the old run was taken: %+v", g.Check) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go index d1d6214e..6636c5f1 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/timers_test.go @@ -27,23 +27,13 @@ func TestALookThatMeetsNothingWaitingOnTheClockWantsNoOtherLook(t *testing.T) { } } -func TestAProposedDeliveryWithNoVerdictHasItsCheckAskedAtOnceWithNoGrace(t *testing.T) { - w := newWorld(t) - w.ctl.down = false - w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - id := IDOf("novox/app", readyHead) - w.h.React() - if w.delivery(id).CheckAsk == nil || len(w.ctl.checks) != 1 { - t.Fatalf("the first look did not ask the check: %v", w.ctl.checks) - } -} - -func TestAProposedDeliveryWhoseHeadHoldsAPassingVerdictAtTheForgeAdoptsItAndAsksNoCheck(t *testing.T) { +func TestAProposedDeliveryWhoseHeadHoldsAPassingMeshCheckAtTheForgeAdoptsItAndAsksNoCheck(t *testing.T) { w := newWorld(t) w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) id := IDOf("novox/app", readyHead) op := openPull("novox/app", 7, readyHead) - op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}, "mesh/repo-check": {State: "success"}} + op.Required = []string{ctxCheck} + op.Checks = map[string]HeadCheck{ctxCheck: {State: "success", Description: "pass " + readyHead[:12] + " | the checker's"}} w.forge.open = []OpenPull{op} w.h.React() w.settleAll() @@ -142,71 +132,3 @@ func TestAnAskTheControllerCouldNotAnswerIsAskedAgainAfterAWhileByTheTimerAlone( t.Fatalf("the look wanted at %s for %q; wanted %s for the plan that could not be asked", at, step, want) } } - -func TestAStandingGateWithNoRepoCheckIsNotAdoptedAndTheCheckIsAsked(t *testing.T) { - w := newWorld(t) - w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - id := IDOf("novox/app", readyHead) - op := openPull("novox/app", 7, readyHead) - op.Required = []string{"mesh/merge-gate", "mesh/repo-check"} - op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}} - w.forge.open = []OpenPull{op} - w.settleAll() - if d := w.delivery(id); d.Check != nil || d.State != Proposed { - t.Fatalf("a gate with no repo-check was adopted: %+v (%s)", d.Check, d.State) - } - if len(w.ctl.checks) != 1 { - t.Fatalf("its check was not asked: %v", w.ctl.checks) - } -} - -func TestAKeptAskWithNoIdIsNoAskAndAStandingVerdictIsAdopted(t *testing.T) { - w := newWorld(t) - w.op.auto = false - w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - id := IDOf("novox/app", readyHead) - w.h.mu.Lock() - w.h.deliveries[id].CheckAsk = &CheckAsk{At: w.now, By: byOwner} - w.h.mu.Unlock() - op := openPull("novox/app", 7, readyHead) - op.Required = []string{"mesh/merge-gate", "mesh/repo-check"} - op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}, "mesh/repo-check": {State: "success"}} - w.forge.open = []OpenPull{op} - w.settleAll() - if d := w.delivery(id); d.State != Ready || d.Check == nil { - t.Fatalf("the standing verdict was not adopted over an ask with no id: %s", d.State) - } - if len(w.ctl.checks) != 0 || w.op.count() != 1 { - t.Fatalf("checks %v, Approves asked %d; wanted none and one", w.ctl.checks, w.op.count()) - } -} - -func TestAKeptAskWithNoIdAndNoStandingVerdictIsAskedAgainAtOnce(t *testing.T) { - w := newWorld(t) - w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - id := IDOf("novox/app", readyHead) - w.h.mu.Lock() - w.h.deliveries[id].CheckAsk = &CheckAsk{At: w.now, By: byOwner} - w.h.mu.Unlock() - w.settleAll() - if d := w.delivery(id); d.CheckAsk == nil || d.CheckAsk.ID == "" || len(w.ctl.checks) != 1 { - t.Fatalf("not asked again: %+v %v", d.CheckAsk, w.ctl.checks) - } -} - -func TestAnEmptyAnswerToACheckAskIsARefusalAskedAtMostOnceAMinute(t *testing.T) { - w := newWorld(t) - w.ctl.emptyCheck = true - w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - id := IDOf("novox/app", readyHead) - w.settleAll() - w.settleAll() - if len(w.ctl.checks) != 1 || w.delivery(id).CheckAsk != nil || len(w.forge.notesOn(readyHead)) > 1 { - t.Fatalf("asked %v, ask kept %v; wanted one ask and none kept", w.ctl.checks, w.delivery(id).CheckAsk) - } - w.later(time.Minute) - w.settleAll() - if len(w.ctl.checks) != 2 { - t.Fatalf("not asked again after the minute: %v", w.ctl.checks) - } -} 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 a4543b06..6258e0f9 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -290,98 +290,16 @@ func (h *Holder) Stalled() []StalledLine { return out } -// Recheck is the `recheck` verb: a rejected or ready delivery proposed again, and its check asked again. -// -// A recheck asks of the head the forge already judged, and the forge keeps the newest status of each -// context on it: left alone, the old verdict stands, and a branch that requires it lets the pull request merge -// on it while the fresh check runs. So the merge check's statuses are put back to pending first — by the -// forge's holder, which keeps them — and only then is the check asked. A forge that cannot be told refuses the -// recheck whole: the delivery stays where it stood, and nothing is asked on a stale verdict. +// Recheck is the `recheck` verb. The checker judges every head and checks it again on every move of its trunk (ADR 0447 §4), and +// the controller judges none (ADR 0524 §5), so there is no ask to make: a delivery that exists is refused, naming the checker +// (the lead's decision for issue 1302), and an unknown id is refused as before. func (h *Holder) Recheck(id, why, by string) (string, error) { h.mu.Lock() - d := h.deliveries[id] - if d == nil { - h.mu.Unlock() + defer h.mu.Unlock() + if h.deliveries[id] == nil { return "", fmt.Errorf("no delivery %q", id) } - if d.judgedByCheck() { - h.mu.Unlock() - return "", checkerRefusal(id) // the lead's decision for issue 1302: the checker serves no ask - } - if d.State == Proposed { - // A proposed delivery's check is asked again when the build queue holds none of its head (issue 1274). - h.mu.Unlock() - return h.recheckProposed(id, why, by) - } - // Whether the table takes it, tried on a copy: nothing is said on the forge for a recheck it refuses. - probe := *d - probe.Transitions = append([]Transition(nil), d.Transitions...) - if _, err := Apply(&probe, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil { - h.mu.Unlock() - return "", err - } - owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number - h.mu.Unlock() - if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil { - return "", fmt.Errorf("%s is not rechecked: %v; its merge check must say pending before it is asked again, "+ - "so nothing merges on the old verdict — ask again once the forge answers", id, err) - } - h.mu.Lock() - t, err := Apply(d, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by}) - if err != nil { - h.mu.Unlock() - return "", fmt.Errorf("%v (its merge check says pending; the forge's next announcement asks it)", err) - } - d.Check = nil - // The composed check of its group goes out of date (novox/hq ADR 0323 §6, issue 867): it was judged for these very - // heads (running or answered: a run's answer is taken by its id and must not stand for the recheck), so without this the kept verdict (a failure above all) stands and the group is never asked again. It is - // asked again once the member is judged again, when every member is ready. - if g := h.groups[d.Group]; g != nil && g.Check != nil { - now := h.Now() - g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why) - g.Check.OutOfDateAt = now - g.Updated = now - h.keepGroup(g) - } - h.moved(d, []Transition{t}) - m := MemberOf(d) - h.mu.Unlock() - asked, err := h.Controller.Check("", []Member{m}) - if err != nil { - return "", fmt.Errorf("%s is proposed again and its check could not be asked yet (%v); the forge's next "+ - "announcement asks it", id, err) - } - return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil -} - -// checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to -// pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every -// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none -// would leave a pending one standing for ever. Asked of the forge's holder, which keeps them; it takes -// pending on them and nothing else, a verdict being the controller's alone (novox/hq issue 293). -func (h *Holder) checkingAgain(owner, repo string, number int, commit, page, why string) error { - if h.Forge == nil { - return errors.New("no forge") - } - // The statuses are owed as keys the forge bridge performs, on the pull request number the delivery has. - contexts := []string{ctxGate, ctxRepo} - if held, err := h.Forge.Statuses(owner, repo, 0, commit, ""); err == nil { - contexts = []string{ctxGate} - for _, s := range held.Statuses { - if s.Context == ctxRepo { - contexts = append(contexts, ctxRepo) - } - } - } - line := clip(oneLine("checking again: "+why), 140) - for _, c := range contexts { - act, value, num, head := owedOf(Effect{Kind: EffectStatus, Commit: commit, Context: c, State: "pending", Line: line}, - owner+"/"+repo, number, commit, "", page) - if err := h.owe(owner+"/"+repo, num, head, act, value); err != nil { - return fmt.Errorf("%s could not be owed as pending on %s: %w", c, shortOf(commit, 8), err) - } - } - return nil + return "", checkerRefusal(id) } // Release is the `release` verb: a held delivery goes on, on a person's word. @@ -670,42 +588,11 @@ func (h *Holder) Close(id, why string) (string, error) { return fmt.Sprintf("%s: %s → %s, as its walk's record says (%s)", id, was, d.State, why), nil } -// reask is H2's close of a proposed delivery past its bound: its check, asked and not answered, asked once -// more — the table's re-ask, which holds only once. The row is tried first on a copy, so an ask the controller -// does not take spends nothing. Called under the lock; it unlocks. +// reask is H2's close of a proposed delivery past its bound. A head's check is the checker's, which asks nothing of anyone +// and checks again on its own (ADR 0447 §4), so there is no check to ask once more. Called under the lock; it unlocks. func (h *Holder) reask(d *Delivery, why string) (string, error) { - id := d.ID - if d.judgedByCheck() { - h.mu.Unlock() - return "", checkerRefusal(id) - } - trial := *d - trial.Transitions = nil - if _, err := Apply(&trial, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why}); err != nil { - h.mu.Unlock() - return "", err - } - m := MemberOf(d) h.mu.Unlock() - asked, err := h.Controller.Check("", []Member{m}) - if err != nil { - return "", fmt.Errorf("%s's check could not be re-asked yet: %w", id, err) - } - h.mu.Lock() - defer h.mu.Unlock() - d = h.deliveries[id] - if d == nil { - return "", fmt.Errorf("no delivery %q", id) - } - t, err := Apply(d, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why + " — re-asked as " + asked}) - if err != nil { - return "", err - } - d.CheckAsk.Reasked++ - d.CheckAsk.ID, d.CheckAsk.At, d.CheckAsk.By, d.CheckAsk.Why = asked, h.Now(), byH2, why - h.moved(d, []Transition{t}) - return fmt.Sprintf("%s: its check re-asked as %s, once (%s); if that is not answered either, the operator's", id, - asked, why), nil + return "", checkerRefusal(d.ID) } // WhatIf is the `what-if` verb. diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index 0ac8e281..086ad999 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -30,7 +30,6 @@ "gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", - "mesh-controller.checked", "mesh-controller.plan-moved", "mesh-delivery-forge-bridge.forge.**", "mesh-config.config-changed" @@ -45,8 +44,6 @@ "seat:mesh-controller.delivery-stop", "seat:mesh-controller.delivery-backups-again", "seat:mesh-controller.delivery-walks", - "seat:mesh-controller.delivery-check", - "seat:mesh-controller.queue", "seat:mesh-controller.plans", "gitea.gitea_commit_statuses", "gitea.gitea_contains", diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index 581a29b3..803adfc8 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -34,7 +34,7 @@ what it refuses. folders until the import, and hq ADR 0284): a status is one of `open`, `diagnosing`, `located`, `fixing`, `resolved`, `wontfix`; `diagnosing` and `fixing` need `by`; `located` needs `located-in`; `resolved` is reached only from `fixing` and needs `located-in` and `fixed-by`; a core issue (located in the controller, the node-engine, the tool runner, -the SDK, or the catalogue's bus, forge or build agent) resolves with `replay` or `replay-none`; `wontfix` +the SDK, the delivery parts, or the catalogue's bus, forge or build modules) resolves with `replay` or `replay-none`; `wontfix` needs a `reason`. A refused change changes nothing and says which rule refused it. **Who works on it** (hq ADR 0284). A move to `diagnosing` or `fixing` names in `by` the issue's **worker**: who diff --git a/modules/mesh-issues/cmd/mesh-issues/check.go b/modules/mesh-issues/cmd/mesh-issues/check.go index 3cb2bae9..b29b45a9 100644 --- a/modules/mesh-issues/cmd/mesh-issues/check.go +++ b/modules/mesh-issues/cmd/mesh-issues/check.go @@ -174,9 +174,10 @@ func (o Owners) locatedIn(e string) (string, error) { } // core is an owner whose issues are core issues (hq cycle.py, to-be 45): the controller, the node-engine, -// the tool runner and the console, the SDK, and the catalogue's bus, forge and build agent. -var core = regexp.MustCompile(`\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|` + - `mesh-catalog\s+modules/(nats|gitea|build-agent))\b`) +// the tool runner and the console, the SDK, the delivery parts, and the catalogue's bus, forge and build modules +// (issue 1041; ADR 0524 §5). +var core = regexp.MustCompile(`\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|mesh-delivery|` + + `mesh-build-(go|node|image)|mesh-catalog\s+modules/(nats|gitea))\b`) // IsCore says whether an issue located in these owners is a core issue (ADR 0237). func IsCore(locatedIn []string) bool { diff --git a/modules/mesh-issues/cmd/mesh-issues/core_test.go b/modules/mesh-issues/cmd/mesh-issues/core_test.go new file mode 100644 index 00000000..8057324a --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/core_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Issue 1041 (R4g, E3): the build seat's modules and the delivery parts are core, and build-agent is no owner any more. +func TestIsCoreNamesTheBuildModulesAndTheDeliveryParts(t *testing.T) { + for _, e := range []string{ + "mesh-catalog modules/mesh-build-go", "mesh-catalog modules/mesh-build-node", "mesh-catalog modules/mesh-build-image", + "mesh-catalog modules/mesh-build-go/cmd/mesh-build-go", + "mesh-delivery", "mesh-delivery modules/mesh-delivery-checker", "mesh-catalog modules/mesh-delivery-gate", + "mesh-catalog modules/nats", "mesh-catalog modules/gitea", "mesh-controller", + } { + if !IsCore([]string{e}) { + t.Errorf("an issue located in %q is not core", e) + } + } + for _, e := range []string{"mesh-catalog modules/records", "hq", "mesh-catalog modules/mesh-issues", "mesh-catalog modules/mesh-build-extra"} { + if IsCore([]string{e}) { + t.Errorf("an issue located in %q is core", e) + } + } +} + +func TestNoOwnerNamesBuildAgent(t *testing.T) { + if IsCore([]string{"mesh-catalog modules/build-agent"}) { + t.Error("mesh-catalog modules/build-agent is core: build-agent is gone") + } + tmpl, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json")) + if err != nil { + t.Fatal(err) + } + var s Settings + if err := json.Unmarshal(tmpl, &s); err != nil { + t.Fatal(err) + } + for _, o := range s.Owners { + if strings.Contains(o, "build-agent") { + t.Errorf("the owner %q is registered: build-agent is gone (ADR 0524 §5)", o) + } + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index ec5adb20..327506f7 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -547,7 +547,7 @@ func cycleRules(is *Issue, from, to string) error { } if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" { return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+ - "runner, the SDK, or the catalogue's bus, forge or build agent): it resolves with replay, the replay's id "+ + "runner, the SDK, the delivery parts, or the catalogue's bus, forge or build modules): it resolves with replay, the replay's id "+ "in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number) } case StatusWontfix: diff --git a/modules/mesh-issues/files/settings.json b/modules/mesh-issues/files/settings.json index 340d44a0..0231a55d 100644 --- a/modules/mesh-issues/files/settings.json +++ b/modules/mesh-issues/files/settings.json @@ -141,7 +141,6 @@ "mesh-catalog modules/zsh-autosuggestions", "mesh-catalog modules/zsh-syntax-highlighting", "mesh-controller", - "mesh-controller modules/build-agent", "mesh-controller modules/route-proxy", "mesh-delivery", "mesh-delivery modules/mesh-delivery-builder", diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index 32a95787..388ec54c 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -234,7 +234,6 @@ "mesh-catalog modules/zsh-autosuggestions", "mesh-catalog modules/zsh-syntax-highlighting", "mesh-controller", - "mesh-controller modules/build-agent", "mesh-controller modules/route-proxy", "mesh-delivery", "mesh-delivery modules/mesh-delivery-builder", diff --git a/modules/nats/cmd/nats-tools/server_test.go b/modules/nats/cmd/nats-tools/server_test.go index acff8f8f..8ade21ea 100644 --- a/modules/nats/cmd/nats-tools/server_test.go +++ b/modules/nats/cmd/nats-tools/server_test.go @@ -96,9 +96,9 @@ func TestAConsumerWithSeveralFiltersIsHandedEveryMessage(t *testing.T) { FilterSubjects: []string{ "mesh.mod.mesh-catalog.event.upgraded", "mesh.mod.mesh-catalog.event.catching-up", - "mesh.seat.node-build-agent.event.built", + "mesh.seat.node-backup.event.done", "mesh.mod.gitea.event.pull.merged", - "mesh.seat.mesh-build-machine.event.built", + "mesh.seat.node-power.event.slept", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", }, @@ -128,7 +128,7 @@ func TestAConsumerWithSeveralFiltersIsHandedEveryMessage(t *testing.T) { } }() - followed := []string{"mesh.mod.gitea.event.pull.merged", "mesh.seat.node-build-agent.event.built", + followed := []string{"mesh.mod.gitea.event.pull.merged", "mesh.seat.node-backup.event.done", "mesh.mod.postgres.event.provisioner.recovered", "mesh.mod.mesh-catalog.event.catching-up"} var mu sync.Mutex var sent []uint64 @@ -153,7 +153,7 @@ func TestAConsumerWithSeveralFiltersIsHandedEveryMessage(t *testing.T) { mu.Unlock() } } else { - _, _ = pjs.Publish(fmt.Sprintf("mesh.seat.node-build-agent.event.log.build-%d-%d", w, i/50), make([]byte, 200)) + _, _ = pjs.Publish(fmt.Sprintf("mesh.seat.node-backup.event.log.run-%d-%d", w, i/50), make([]byte, 200)) } if rand.Intn(100) == 0 { time.Sleep(time.Duration(rand.Intn(300)) * time.Millisecond)