Judge a pull request into a release branch by that branch's controller and repositories
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 4a75cc51dda6 merged into trunk 535c7641ac41 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 4a75cc51dda6 merged into trunk 535c7641ac41 — pass: its merge-check.sh passed

A release branch could never carry a change that needs another repository's
release-branch change: its pull requests were judged by the running controller
and against the other repositories' main (hq issue 704, ADR 0358).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:48:44 +00:00
co-authored by Claude Opus 5.5
parent 535c7641ac
commit 4a75cc51dd
8 changed files with 924 additions and 35 deletions
+14 -3
View File
@@ -24,6 +24,7 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"sort" "sort"
"strings" "strings"
"syscall" "syscall"
@@ -313,6 +314,9 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
registry = r.Address registry = r.Address
} }
spec := checkSpecOf(request) spec := checkSpecOf(request)
// A release branch's judge is kept beside the workspace, never in it: the check's containers mount the
// workspace, and a pull request's script must not plant the judge of the next check (novox/hq ADR 0358 §2).
spec.JudgeCache = judgeCacheOf(workspace)
// What the merge would move, built by the check for the walk to take (novox/hq ADR 0350 §1): into the // What the merge would move, built by the check for the walk to take (novox/hq ADR 0350 §1): into the
// scratch namespace. **Never with the package registry's credential**: this is code nobody has approved // scratch namespace. **Never with the package registry's credential**: this is code nobody has approved
// yet, and a recipe could read the credential it is handed (issue 462). A module that resolves packages // yet, and a recipe could read the credential it is handed (issue 462). A module that resolves packages
@@ -326,7 +330,8 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
if err == nil { if err == nil {
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report, result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo), Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built)} Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built),
Release: v.Release, JudgedBy: v.JudgedBy, BesideAt: v.BesideAt}
// What each of a group's heads was judged as (novox/hq ADR 0323 §6). // What each of a group's heads was judged as (novox/hq ADR 0323 §6).
for _, m := range v.Members { for _, m := range v.Members {
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
@@ -456,9 +461,9 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{}, Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{},
Modules: c.Modules, New: c.New, Manifests: c.Manifests, Judge: c.Judge, Base: c.Base, Modules: c.Modules, New: c.New, Manifests: c.Manifests, Judge: c.Judge, Base: c.Base,
Toolchain: builder.ToolchainOf(request.Held), Toolchains: builder.ToolchainsOf(request.Held), Toolchain: builder.ToolchainOf(request.Held), Toolchains: builder.ToolchainsOf(request.Held),
Tests: c.Tests, StoreOnly: c.StoreOnly} Tests: c.Tests, StoreOnly: c.StoreOnly, Release: c.Release}
for dir, b := range c.Beside { for dir, b := range c.Beside {
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref} spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref, Else: b.Else}
} }
for _, b := range c.Builds { for _, b := range c.Builds {
spec.Builds = append(spec.Builds, builder.CheckBuildSpec{Module: b.Module, Repository: b.Repository, Path: b.Path, spec.Builds = append(spec.Builds, builder.CheckBuildSpec{Module: b.Module, Repository: b.Repository, Path: b.Path,
@@ -471,6 +476,12 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
return spec return spec
} }
// judgeCacheOf is where a release branch's judge is kept (novox/hq ADR 0358 §2): beside the workspace, outside what
// a check's containers mount.
func judgeCacheOf(workspace string) string {
return filepath.Clean(workspace) + "-judges"
}
// triedOf is a try's outcome as its log line says it (novox/hq issue 686): the repository's own check's verdict, // triedOf is a try's outcome as its log line says it (novox/hq issue 686): the repository's own check's verdict,
// or the check's where that did not run — a head that does not merge — and the lines naming what failed. // or the check's where that did not run — a head that does not merge — and the lines naming what failed.
func triedOf(v builder.CheckVerdict) link.Tried { func triedOf(v builder.CheckVerdict) link.Tried {
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/link"
)
// **A pull request into a release branch reaches the check as one** (novox/hq ADR 0358): the build seat hands the
// check the release branch and, beside it, the trunk each repository is read at when it has no branch of that name;
// and it keeps the release branch's judge outside the workspace a check's containers mount.
func TestTheBuildSeatHandsTheCheckItsReleaseBranch(t *testing.T) {
spec := checkSpecOf(link.BuildRequest{ID: "b", Repository: "r", Ref: "abc", Check: &link.CheckRequest{
Owner: "novox", Repo: "mesh-catalog", Base: "feat/57", Release: "feat/57",
Beside: map[string]link.CheckedOut{"mesh-host": {Repository: "h", Ref: "feat/57", Else: "main"}}}})
if spec.Release != "feat/57" || spec.Beside["mesh-host"].Ref != "feat/57" || spec.Beside["mesh-host"].Else != "main" {
t.Fatalf("the check was handed release %q and mesh-host %+v", spec.Release, spec.Beside["mesh-host"])
}
workspace := filepath.Join(t.TempDir(), "mesh-builder")
cache := judgeCacheOf(workspace)
if rel, err := filepath.Rel(workspace, cache); err != nil || !strings.HasPrefix(rel, "..") {
t.Fatalf("the judge cache %s lies inside the workspace %s, which a check's containers mount", cache, workspace)
}
}
+140 -22
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"path" "path"
"regexp" "regexp"
@@ -248,30 +249,14 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
if err != nil { if err != nil {
return link.BuildRequest{}, err return link.BuildRequest{}, err
} }
// Beside it, at what the mesh runs: each core repository by the module the mesh builds from it. // **A pull request into a release branch is judged by that branch** (novox/hq ADR 0358): asked of the forge once.
beside := map[string]link.CheckedOut{} release, err := releaseBranchOf(ctx, p, scope, entries)
for _, e := range entries {
dir, core := coreModules[e.Manifest.Module]
if !core || e.Provided || e.Source.Repository == "" {
continue
}
url, err := clone(e.Source)
if err != nil { if err != nil {
return link.BuildRequest{}, err return link.BuildRequest{}, err
} }
refs := besideRefs(dir, current[e.Manifest.Module].Commit) beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone, release)
beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]} if err != nil {
if dir == "mesh-controller" { return link.BuildRequest{}, err
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
if e.Source.Seat != "" {
for _, sibling := range []string{"mesh-lab", "mesh-sdk"} {
if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
sibling)}); err == nil {
beside[sibling] = link.CheckedOut{Repository: url, Ref: refs[sibling]}
}
}
}
}
} }
builds := checkBuildsOf(p, scope, entries, clone) builds := checkBuildsOf(p, scope, entries, clone)
return link.BuildRequest{ return link.BuildRequest{
@@ -283,10 +268,142 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
Source: sourceOnSeat(source), Source: sourceOnSeat(source),
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base, Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New, Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: namedForTheCheck(ctx, p, builds)}, Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: namedForTheCheck(ctx, p, builds),
Release: release},
}, nil }, nil
} }
// besideOf is what a check reads beside its own repository, by the directory it is found under: each core repository
// by the module the mesh builds from it, at besideRefs — or, for a pull request into a release branch (novox/hq ADR
// 0358 §3), at its branch of that name where it has one and at its trunk otherwise, the controller's main kept for
// its judge. running is the commit the mesh runs of a module.
func besideOf(entries []inventory.Entry, running func(module string) string,
clone func(inventory.Source) (string, error), release string) (map[string]link.CheckedOut, error) {
beside := map[string]link.CheckedOut{}
for _, e := range entries {
dir, core := coreModules[e.Manifest.Module]
if !core || e.Provided || e.Source.Repository == "" {
continue
}
url, err := clone(e.Source)
if err != nil {
return nil, err
}
refs := besideRefs(dir, running(e.Manifest.Module))
at := func(dir, trunk string) link.CheckedOut {
if release == "" {
return link.CheckedOut{Repository: url, Ref: refs[dir]}
}
if trunk == "" {
trunk = "main"
}
return link.CheckedOut{Repository: url, Ref: release, Else: trunk}
}
beside[dir] = at(dir, followedBranch(e.Source.Ref))
if dir == "mesh-controller" {
// The controller's main stays its main: it judges only a check the running controller predates.
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
if release != "" {
// mesh-controller has the release branch: that is what makes it one (ADR 0358 §1).
beside[dir] = link.CheckedOut{Repository: url, Ref: release}
}
if e.Source.Seat != "" {
for _, sibling := range []string{"mesh-lab", "mesh-sdk"} {
if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
sibling)}); err == nil {
b := link.CheckedOut{Repository: url, Ref: refs[sibling]}
if release != "" {
b = link.CheckedOut{Repository: url, Ref: release, Else: "main"}
}
beside[sibling] = b
}
}
}
}
}
return beside, nil
}
// releaseBranchOf is the release branch a pull request merges into (novox/hq ADR 0358 §1): its base, when that is not
// its repository's trunk and mesh-controller has a branch of that name; empty otherwise, and the pull request is
// checked as before it. **A base that is a trunk asks nothing**: main, the branch a module built from the repository
// follows, or mesh-controller's own trunk. Any other base is asked of the forge once, and an answer it cannot give is
// an error, never read as "no release branch".
func releaseBranchOf(ctx context.Context, p link.PullUpdated, scope checkScope, entries []inventory.Entry) (string, error) {
base := p.Base
if base == "" || base == "main" {
return "", nil
}
if scope.From != nil && base == followedBranch(scope.From.Source.Ref) {
return "", nil
}
var controller *inventory.Entry
for i, e := range entries {
if e.Manifest.Module == "mesh-controller" && !e.Provided && e.Source.Repository != "" {
controller = &entries[i]
break
}
}
if controller == nil || base == followedBranch(controller.Source.Ref) {
return "", nil
}
if !tryRef.MatchString(base) || strings.Contains(base, "..") {
return "", nil // no branch the forge could hold: checked as before
}
owner, repo := sourceOwner(controller.Source.Repository), sourceName(controller.Source.Repository)
if owner == "" || repo == "" {
return "", nil
}
has, err := forgeHasBranch(ctx, owner, repo, base)
if err != nil {
return "", fmt.Errorf("whether %s/%s#%d merges into a release branch cannot be told — the forge did not say "+
"whether %s/%s has a branch %s (novox/hq ADR 0358): %w", p.Owner, p.Repo, p.Number, owner, repo, base, err)
}
if !has {
return "", nil
}
return base, nil
}
// sourceName is the name of a recorded repository, a path on the git seat or a URL: novox/mesh-host → mesh-host.
func sourceName(repository string) string {
parts := strings.Split(strings.Trim(strings.TrimSuffix(repository, ".git"), "/"), "/")
if len(parts) < 2 {
return ""
}
return parts[len(parts)-1]
}
// forgeHasBranch asks the forge, through its module's tools on the bus, whether a repository has a branch. A
// variable so a test needs no forge.
var forgeHasBranch = func(ctx context.Context, owner, repo, branch string) (bool, error) {
js, err := aBus()
if err != nil {
return false, err
}
defer js.Close()
raw, _ := json.Marshal(map[string]any{"path": "/repos/" + owner + "/" + repo + "/branches/" + branch})
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
if err != nil {
return false, err
}
if answer.Error != "" {
if strings.Contains(answer.Error, " 404 ") {
return false, nil
}
return false, errors.New(answer.Error)
}
var found struct {
Result struct {
Name string `json:"name"`
} `json:"result"`
}
if err := json.Unmarshal(answer.Result, &found); err != nil {
return false, err
}
return found.Result.Name == branch, nil
}
// namedForTheCheck are the builds earlier checks of the same pull request at the same head named, read once for the // namedForTheCheck are the builds earlier checks of the same pull request at the same head named, read once for the
// check (novox/hq issue 703): asked again because only the trunk moved, the check keeps what they built of a key // check (novox/hq issue 703): asked again because only the trunk moved, the check keeps what they built of a key
// still the same. Another pull request's build, or one of an earlier head, is never kept. None when they cannot be // still the same. Another pull request's build, or one of an earlier head, is never kept. None when they cannot be
@@ -458,6 +575,7 @@ func checkedOf(result link.BuildResult) link.Checked {
// repositories read beside it. // repositories read beside it.
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
c.Built = result.Check.Built c.Built = result.Check.Built
c.Release, c.JudgedBy, c.BesideAt = result.Check.Release, result.Check.JudgedBy, result.Check.BesideAt
if c.Gate == nil { if c.Gate == nil {
// A build seat from before the layers: its verdict is the gate's. // A build seat from before the layers: its verdict is the gate's.
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// **A pull request into a release branch is judged by that branch** (novox/hq ADR 0358, issue 704): a base that is
// not its repository's trunk, where mesh-controller has a branch of that name, is a release branch; the check then
// reads every repository beside it at its branch of that name where it has one, else at its trunk. Any other base is
// checked exactly as before.
const aRelease = "feat/57-config-and-provisioning"
// theCore is the mesh's core as a check reads it beside: the controller, the node-engine, the tool runner and the
// catalogue's bus, on the git seat.
func theCore() []inventory.Entry {
onSeat := func(module, repository string) inventory.Entry {
e := fromRepo(module, repository, "")
e.Source.Seat = "git"
return e
}
nats := onSeat("nats", "novox/mesh-catalog")
nats.Source.Path = "modules/nats"
return []inventory.Entry{onSeat("mesh-controller", "novox/mesh-controller"), onSeat("mesh-host", "novox/mesh-host"),
onSeat("node-tools", "novox/mesh-tools"), nats, onSeat("gitea", "novox/mesh-catalog")}
}
// aForgeWithBranches stands in for the forge: which branches each repository has, and how often it was asked.
func aForgeWithBranches(t *testing.T, branches map[string][]string, failing error) *int {
t.Helper()
asked := 0
was := forgeHasBranch
t.Cleanup(func() { forgeHasBranch = was })
forgeHasBranch = func(_ context.Context, owner, repo, branch string) (bool, error) {
asked++
if failing != nil {
return false, failing
}
for _, b := range branches[owner+"/"+repo] {
if b == branch {
return true, nil
}
}
return false, nil
}
return &asked
}
func TestABaseIsAReleaseBranchWhenTheControllerHasABranchOfThatName(t *testing.T) {
entries := theCore()
scopeOf := func(p link.PullUpdated) checkScope { return pullScope(p, entries, nil, nil) }
pull := func(repo, base string) link.PullUpdated {
return link.PullUpdated{Owner: "novox", Repo: repo, Number: 286, Base: base, Commit: "abc",
Paths: []string{"modules/gitea/index.ts"}}
}
asked := aForgeWithBranches(t, map[string][]string{"novox/mesh-controller": {"main", aRelease}}, nil)
for _, c := range []struct {
what string
p link.PullUpdated
release string
asks int
}{
{"a pull request into the trunk asks nothing and is no release", pull("mesh-catalog", "main"), "", 0},
{"a catalogue pull request into the release branch", pull("mesh-catalog", aRelease), aRelease, 1},
{"a controller pull request into the release branch", pull("mesh-controller", aRelease), aRelease, 1},
{"a base mesh-controller has no branch of is checked as today", pull("mesh-catalog", "feat/other"), "", 1},
{"no base: as today", pull("mesh-catalog", ""), "", 0},
} {
*asked = 0
got, err := releaseBranchOf(t.Context(), c.p, scopeOf(c.p), entries)
if err != nil {
t.Fatalf("%s: %v", c.what, err)
}
if got != c.release || *asked != c.asks {
t.Errorf("%s: release %q after %d question(s) to the forge, not %q after %d", c.what, got, *asked,
c.release, c.asks)
}
}
// The branch a module built from the repository follows is its trunk: never asked about.
photos := fromRepo("photos", "novox/photos", "")
photos.Source.Ref = "nox-mesh"
withPhotos := append(theCore(), photos)
p := link.PullUpdated{Owner: "novox", Repo: "photos", Base: "nox-mesh", Commit: "abc", Paths: []string{"x.js"}}
*asked = 0
if got, err := releaseBranchOf(t.Context(), p, pullScope(p, withPhotos, nil, nil), withPhotos); err != nil ||
got != "" || *asked != 0 {
t.Fatalf("a module's own trunk read as release %q (%v) after %d question(s)", got, err, *asked)
}
// A forge that cannot answer is an error, never "no release branch".
aForgeWithBranches(t, nil, errors.New("nothing serves gitea.gitea_api"))
q := pull("mesh-catalog", aRelease)
if _, err := releaseBranchOf(t.Context(), q, scopeOf(q), entries); err == nil ||
!strings.Contains(err.Error(), "cannot be told") {
t.Fatalf("an unanswered forge was read as an answer: %v", err)
}
}
func TestBesideAReleaseBranchCheckEachRepositoryIsReadAtThatBranchElseItsTrunk(t *testing.T) {
entries := theCore()
clone := func(s inventory.Source) (string, error) { return "http://git/" + s.Repository + ".git", nil }
running := func(module string) string { return "running-" + module }
beside, err := besideOf(entries, running, clone, aRelease)
if err != nil {
t.Fatal(err)
}
at := func(repo string, ref, orElse string) link.CheckedOut {
return link.CheckedOut{Repository: "http://git/novox/" + repo + ".git", Ref: ref, Else: orElse}
}
want := map[string]link.CheckedOut{
// The judge: mesh-controller's release branch, which it has.
"mesh-controller": at("mesh-controller", aRelease, ""),
"mesh-controller-main": at("mesh-controller", "main", ""),
// A controller pull request into the release branch reads mesh-host at that branch where it has one.
"mesh-host": at("mesh-host", aRelease, "main"),
"mesh-tools": at("mesh-tools", aRelease, "main"),
"mesh-catalog": at("mesh-catalog", aRelease, "main"),
"mesh-lab": at("mesh-lab", aRelease, "main"),
"mesh-sdk": at("mesh-sdk", aRelease, "main"),
}
if !reflect.DeepEqual(beside, want) {
t.Fatalf("beside a release-branch check:\n got %v\nwant %v", beside, want)
}
// Any other base: exactly as before — what the mesh runs, the catalogue's and the lab's main.
beside, err = besideOf(entries, running, clone, "")
if err != nil {
t.Fatal(err)
}
want = map[string]link.CheckedOut{
"mesh-controller": at("mesh-controller", "running-mesh-controller", ""),
"mesh-controller-main": at("mesh-controller", "main", ""),
"mesh-host": at("mesh-host", "running-mesh-host", ""),
"mesh-tools": at("mesh-tools", "running-node-tools", ""),
"mesh-catalog": at("mesh-catalog", "main", ""),
"mesh-lab": at("mesh-lab", "main", ""),
"mesh-sdk": at("mesh-sdk", sdkPinned(), ""),
}
if !reflect.DeepEqual(beside, want) {
t.Fatalf("beside a trunk check:\n got %v\nwant %v", beside, want)
}
}
func TestAReleaseBranchVerdictNamesTheBranchTheJudgeAndWhatItRead(t *testing.T) {
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: aRelease, Release: aRelease}
c := checkedOf(link.BuildResult{ID: "b", Ref: "aaaaaaaaaaaa0000", Checked: asked, Check: &link.CheckOutcome{
Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"},
RepoCheck: &link.CheckLayer{Verdict: "pass"}, Release: aRelease, JudgedBy: "fcdecb436117",
Beside: map[string]string{"mesh-host": "222222222222", "mesh-lab": "333333333333"},
BesideAt: map[string]string{"mesh-host": aRelease, "mesh-lab": "main"}}})
if c.Release != aRelease || c.JudgedBy != "fcdecb436117" {
t.Fatalf("the verdict names release %q judged by %q", c.Release, c.JudgedBy)
}
if c.BesideAt["mesh-host"] != aRelease || c.BesideAt["mesh-lab"] != "main" || c.Beside["mesh-host"] != "222222222222" {
t.Fatalf("the verdict names what it read beside it as %v at %v", c.Beside, c.BesideAt)
}
}
+244 -7
View File
@@ -13,6 +13,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "time"
@@ -87,6 +88,16 @@ type CheckSpec struct {
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator" // Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
// the running one with the change's validator. // the running one with the change's validator.
Judge string Judge string
// Release is the release branch the pull request merges into (novox/hq ADR 0358), empty for any other base. The
// gate is then judged by the controller beside it as mesh-controller — mesh-controller's release branch, never
// the controller the mesh runs nor its main — with mesh-host's validator from beside it when mesh-host was read at
// the release branch too; a change to the controller still judges itself and a change to the node-engine brings
// its own validator. The verdict names the branch, the judge's commit and the branch each beside was read at.
Release string
// JudgeCache is a directory outside the workspace where a release branch's judge is kept by the commits it is
// built from, so it is built once per release-branch head (ADR 0358 §2). Outside: the check's containers mount
// the workspace and a pull request's own script must not plant a judge for the next check. Empty keeps none.
JudgeCache string
// Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it. // Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it.
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another. // Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
Toolchain string Toolchain string
@@ -153,10 +164,12 @@ func ToolchainsOf(held map[string]string) map[string]string {
return out return out
} }
// Beside is one repository cloned next to the one checked. // Beside is one repository cloned next to the one checked: at Ref, or at Else when Else is said and the repository
// has no branch Ref (novox/hq ADR 0358 §3).
type Beside struct { type Beside struct {
Repository string Repository string
Ref string Ref string
Else string
} }
// CheckVerdict is what came of one check. Verdict and Summary are the gate's; Gate and Repo each layer. // CheckVerdict is what came of one check. Verdict and Summary are the gate's; Gate and Repo each layer.
@@ -179,6 +192,12 @@ type CheckVerdict struct {
Members []JudgedHead Members []JudgedHead
// Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not. // Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not.
Built []CheckBuilt Built []CheckBuilt
// Release is the release branch it was judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the controller
// that judged its gate (twelve characters), and BesideAt the branch each repository beside it was read at, by
// directory. Empty for a check of any other base.
Release string
JudgedBy string
BesideAt map[string]string
} }
// Layer is one layer of a check, judged. // Layer is one layer of a check, judged.
@@ -303,13 +322,23 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
if err := forgetLeftCredentials(workspace); err != nil { if err := forgetLeftCredentials(workspace); err != nil {
return CheckVerdict{}, err return CheckVerdict{}, err
} }
clone := func(repository, ref, dir string) error { // cloneAt clones a repository and checks out ref — or, when the repository has no branch ref and orElse is said,
// orElse (novox/hq ADR 0358 §3); readAt keeps which, by directory.
readAt := map[string]string{}
cloneAt := func(repository, ref, orElse, dir string) error {
if _, err := run(ctx, root, "git", cloneWith(credentials, "clone", "--quiet", repository, dir)...); err != nil { if _, err := run(ctx, root, "git", cloneWith(credentials, "clone", "--quiet", repository, dir)...); err != nil {
return fmt.Errorf("cannot clone %s: %s", redact.redact(repository), redact.redact(err.Error())) return fmt.Errorf("cannot clone %s: %s", redact.redact(repository), redact.redact(err.Error()))
} }
if err := recordedWithoutUserinfo(ctx, run, filepath.Join(root, dir), repository); err != nil { if err := recordedWithoutUserinfo(ctx, run, filepath.Join(root, dir), repository); err != nil {
return err return err
} }
if orElse != "" && ref != "" {
if _, err := run(ctx, filepath.Join(root, dir), "git", "rev-parse", "--verify", "--quiet",
"refs/remotes/origin/"+ref); err != nil {
ref = orElse
}
}
readAt[dir] = ref
if ref != "" { if ref != "" {
if _, err := run(ctx, filepath.Join(root, dir), "git", "checkout", "--quiet", ref); err != nil { if _, err := run(ctx, filepath.Join(root, dir), "git", "checkout", "--quiet", ref); err != nil {
return fmt.Errorf("%s has no %s: %w", redact.redact(repository), ref, err) return fmt.Errorf("%s has no %s: %w", redact.redact(repository), ref, err)
@@ -317,6 +346,9 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
} }
return nil return nil
} }
clone := func(repository, ref, dir string) error {
return cloneAt(repository, ref, "", dir)
}
name := spec.Repo name := spec.Repo
if name == "" || !safeName.MatchString(name) { if name == "" || !safeName.MatchString(name) {
name = "checked" name = "checked"
@@ -352,6 +384,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
} }
named := func(v CheckVerdict) CheckVerdict { named := func(v CheckVerdict) CheckVerdict {
v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members
v.Release, v.JudgedBy, v.BesideAt = spec.Release, judged.JudgedBy, judged.BesideAt
return v return v
} }
// **What the merge would move is built from a checkout nothing of the check touches** (novox/hq issue 703): the // **What the merge would move is built from a checkout nothing of the check touches** (novox/hq issue 703): the
@@ -392,9 +425,16 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
if dir == name || !safeName.MatchString(dir) { if dir == name || !safeName.MatchString(dir) {
continue continue
} }
if err := clone(b.Repository, b.Ref, dir); err != nil { if err := cloneAt(b.Repository, b.Ref, b.Else, dir); err != nil {
return CheckVerdict{}, fmt.Errorf("beside it, %w", err) return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
} }
if spec.Release != "" {
// The branch each was read at is named with its commit (ADR 0358 §4).
if judged.BesideAt == nil {
judged.BesideAt = map[string]string{}
}
judged.BesideAt[dir] = readAt[dir]
}
// The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question. // The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question.
if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" { if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" {
if judged.Beside == nil { if judged.Beside == nil {
@@ -402,7 +442,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
} }
judged.Beside[dir] = at judged.Beside[dir] = at
} }
say("check", "beside it %s at %s", dir, short(b.Ref)) say("check", "beside it %s at %s", dir, refWords(readAt[dir]))
} }
// A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this. // A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this.
@@ -542,10 +582,21 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
// The judge. Its failing to build is the change's fault when the change is the judge or its validator, // The judge. Its failing to build is the change's fault when the change is the judge or its validator,
// and the check's when it is the controller the mesh runs. // and the check's when it is the controller the mesh runs.
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, inToolchain, say) gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, judged.BesideAt, inToolchain, say)
if err != nil { if err != nil {
return CheckVerdict{}, err return CheckVerdict{}, err
} }
if spec.Release != "" {
// **A release-branch judgement says so** (novox/hq ADR 0358 §4): the branch, the controller's commit that
// judged, and each repository read beside it at its branch and commit.
judgedBy := filepath.Join(root, "mesh-controller")
if spec.Judge == "self" {
judgedBy = tree
}
judged.JudgedBy = headAt(ctx, run, judgedBy)
fmt.Fprintf(&out, "--- %s\n", releaseWords(spec.Release, judged.JudgedBy, judged.BesideAt, judged.Beside))
say("check", "%s", releaseWords(spec.Release, judged.JudgedBy, judged.BesideAt, judged.Beside))
}
verdictFile := filepath.Join(root, "verdict.json") verdictFile := filepath.Join(root, "verdict.json")
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL, env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo, EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
@@ -611,6 +662,11 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
} }
} }
if spec.Release != "" {
// The statuses name the release branch and the judge first, so the forge's short description keeps them.
mark := releaseMark(spec.Release, judged.JudgedBy)
v.Gate.Summary, v.Repo.Summary = mark+v.Gate.Summary, mark+v.Repo.Summary
}
// What the check printed travels in the verdict to the forge and the controller: redacted as the log is. // What the check printed travels in the verdict to the forge and the controller: redacted as the log is.
v.Gate.Summary, v.Repo.Summary, v.Repo.Failed = redact.redact(v.Gate.Summary), redact.redact(v.Repo.Summary), v.Gate.Summary, v.Repo.Summary, v.Repo.Failed = redact.redact(v.Gate.Summary), redact.redact(v.Repo.Summary),
redact.redact(v.Repo.Failed) redact.redact(v.Repo.Failed)
@@ -968,7 +1024,12 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
// the mesh runs. It answers the judge's path; or, when the change makes its own judge unbuildable, why — // the mesh runs. It answers the judge's path; or, when the change makes its own judge unbuildable, why —
// the change's fault, a failing gate; or an error when the check cannot build a judge at all. A check // the change's fault, a failing gate; or an error when the check cannot build a judge at all. A check
// whose gate does not run builds a judge only to hand its scripts one, and goes on without when it cannot. // whose gate does not run builds a judge only to hand its scripts one, and goes on without when it cannot.
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string, //
// **A pull request into a release branch is judged by that branch's controller** (novox/hq ADR 0358 §2): the one
// beside it as mesh-controller, read at the release branch — with mesh-host's validator from beside it when mesh-host
// has that branch too, or with the change's own validator for a change to the node-engine — never the controller the
// mesh runs. besideAt is the branch each repository beside it was read at.
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string, besideAt map[string]string,
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) { inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
gated := spec.Gated() gated := spec.Gated()
switch spec.Judge { switch spec.Judge {
@@ -982,7 +1043,14 @@ func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, t
say("check", "judged by the change's own controller") say("check", "judged by the change's own controller")
return bin, "", nil return bin, "", nil
} }
bin, judgeTree, err := judge(ctx, labelled, run, root, inToolchain, say) var bin, judgeTree string
var err error
if spec.Release != "" {
withHost := spec.Judge != "validator" && besideAt["mesh-host"] == spec.Release
bin, judgeTree, err = releaseJudge(ctx, labelled, run, spec, root, withHost, inToolchain, say)
} else {
bin, judgeTree, err = judge(ctx, labelled, run, root, inToolchain, say)
}
if err != nil { if err != nil {
if !gated { if !gated {
say("check", "no judge for its script: %v", err) say("check", "no judge for its script: %v", err)
@@ -1004,12 +1072,181 @@ func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, t
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree), if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree),
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")}, []string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
"go", "build", "-o", withValidator, "./cmd/mesh-controller")...); err != nil { "go", "build", "-o", withValidator, "./cmd/mesh-controller")...); err != nil {
if spec.Release != "" {
return "", spec.Release + "'s controller does not build with this change's validator: " +
firstLine(err.Error()), nil
}
return "", "the controller the mesh runs does not build with this change's validator: " + firstLine(err.Error()), nil return "", "the controller the mesh runs does not build with this change's validator: " + firstLine(err.Error()), nil
} }
if spec.Release != "" {
say("check", "judged by %s's controller, with this change's validator", spec.Release)
return withValidator, "", nil
}
say("check", "judged by the controller the mesh runs, with this change's validator") say("check", "judged by the controller the mesh runs, with this change's validator")
return withValidator, "", nil return withValidator, "", nil
} }
// releaseJudge builds the controller that judges a pull request into a release branch (novox/hq ADR 0358 §2): the
// one beside the check as mesh-controller, read at that branch — with mesh-host's validator from beside it in place
// of the one it vendors when withHost. **Built once per release-branch head**: kept in the spec's judge cache by the
// commits it is built from and taken from there while they stand. It answers the binary and the directory it was
// built from.
func releaseJudge(ctx context.Context, run, plain Runner, spec CheckSpec, root string, withHost bool,
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
const dir = "mesh-controller"
if _, err := os.Stat(filepath.Join(root, dir)); err != nil {
return "", "", fmt.Errorf("no controller of %s beside the check to judge it with", spec.Release)
}
key := fullHeadAt(ctx, plain, filepath.Join(root, dir))
if key == "" {
return "", "", fmt.Errorf("the controller of %s beside the check names no commit", spec.Release)
}
with := ""
if withHost {
host := fullHeadAt(ctx, plain, filepath.Join(root, "mesh-host"))
if host == "" {
return "", "", fmt.Errorf("mesh-host of %s beside the check names no commit", spec.Release)
}
key += "-with-mesh-host-" + host
with = ", with mesh-host's validator of " + spec.Release + " at " + twelve(host)
}
bin := filepath.Join(root, "bin", "mesh-controller")
if took := judgeKept(spec.JudgeCache, key, bin); took {
say("check", "judged by %s's controller at %s%s, built for an earlier check of that head", spec.Release,
twelve(key), with)
return bin, dir, nil
}
if withHost {
vendored := filepath.Join(root, dir, "vendor", "github.com", "novox", "mesh-host")
for _, pkg := range []string{"validate", filepath.Join("internal", "declaration")} {
if err := replaceGoFiles(filepath.Join(root, "mesh-host", pkg), filepath.Join(vendored, pkg)); err != nil {
return "", "", fmt.Errorf("mesh-host's validator of %s could not be put in its controller: %w", spec.Release, err)
}
}
}
if _, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir),
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
return "", "", fmt.Errorf("the controller of %s does not build%s: %w", spec.Release, with, err)
}
// Kept before anything of the change runs: nothing a pull request's script writes can reach the cache.
keepJudge(spec.JudgeCache, key, bin, say)
say("check", "judged by %s's controller at %s%s", spec.Release, twelve(key), with)
return bin, dir, nil
}
// judgesKept is how many release-branch judges a judge cache keeps: a head moved past is not built from again.
const judgesKept = 4
// judgeKept puts the judge kept under key in the cache at bin, and says whether there was one.
func judgeKept(cache, key, bin string) bool {
if cache == "" {
return false
}
body, err := os.ReadFile(filepath.Join(cache, key, "mesh-controller"))
if err != nil {
return false
}
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
return false
}
if err := os.WriteFile(bin, body, 0o755); err != nil {
return false
}
now := time.Now()
_ = os.Chtimes(filepath.Join(cache, key), now, now)
return true
}
// keepJudge keeps a judge just built under key, and lets go of all but the judgesKept most recently used. A judge
// that cannot be kept is said and built again next time: the cache only saves time.
func keepJudge(cache, key, bin string, say func(step, format string, args ...any)) {
if cache == "" {
return
}
body, err := os.ReadFile(bin)
if err == nil {
err = os.MkdirAll(filepath.Join(cache, key), 0o755)
}
if err == nil {
kept := filepath.Join(cache, key, "mesh-controller")
if err = os.WriteFile(kept+".part", body, 0o755); err == nil {
err = os.Rename(kept+".part", kept)
}
}
if err != nil {
say("check", "the judge could not be kept for the next check, which builds it again: %v", err)
return
}
entries, err := os.ReadDir(cache)
if err != nil || len(entries) <= judgesKept {
return
}
type used struct {
name string
at time.Time
}
var all []used
for _, e := range entries {
if info, err := e.Info(); err == nil && e.IsDir() {
all = append(all, used{e.Name(), info.ModTime()})
}
}
sort.Slice(all, func(i, j int) bool { return all[i].at.After(all[j].at) })
for _, u := range all[min(judgesKept, len(all)):] {
if u.name != key {
_ = os.RemoveAll(filepath.Join(cache, u.name))
}
}
}
// fullHeadAt is the whole commit a checkout stands at, empty when it names none.
func fullHeadAt(ctx context.Context, run Runner, tree string) string {
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
if err != nil {
return ""
}
return strings.TrimSpace(out)
}
// releaseWords is how a release-branch check says what judged it and what it read (novox/hq ADR 0358 §4).
func releaseWords(release, judgedBy string, at, commits map[string]string) string {
var read []string
for _, dir := range sortedKeys(at) {
read = append(read, fmt.Sprintf("%s %s at %s", dir, refWords(at[dir]), commits[dir]))
}
said := fmt.Sprintf("judged for the release branch %s by its controller at %s", release, judgedBy)
if len(read) > 0 {
said += "; read beside it: " + strings.Join(read, ", ")
}
return said
}
// releaseMark opens each layer's summary of a release-branch check, so a status says it first.
func releaseMark(release, judgedBy string) string {
return fmt.Sprintf("[release %s, judged by its controller %s] ", release, judgedBy)
}
// refWords is a ref as a line says it: a commit by its first eight characters, a branch whole.
func refWords(ref string) string {
if commitLike.MatchString(ref) {
return short(ref)
}
return ref
}
// commitLike is a ref that names a commit.
var commitLike = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// replaceGoFiles puts a package's Go files — never its tests — in place of another copy's. // replaceGoFiles puts a package's Go files — never its tests — in place of another copy's.
func replaceGoFiles(from, into string) error { func replaceGoFiles(from, into string) error {
old, err := filepath.Glob(filepath.Join(into, "*.go")) old, err := filepath.Glob(filepath.Join(into, "*.go"))
+308
View File
@@ -0,0 +1,308 @@
package builder
import (
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// **A pull request into a release branch is judged by the controller of that branch** (novox/hq ADR 0358, issue
// 704): the build seat's check builds the judge from mesh-controller read at the release branch — with mesh-host's
// validator of that branch when mesh-host has one — reads every repository beside it at that branch where it has one
// and at its trunk otherwise, and names the branch, the judge's commit and each repository read with its commit.
const releaseBranch = "feat/57-config-and-provisioning"
// gitInRepo runs git in a repository made for a test.
func gitInRepo(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// commitFiles writes files into a repository and commits them on the branch checked out.
func commitFiles(t *testing.T, dir string, files map[string]string) string {
t.Helper()
for name, body := range files {
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
gitInRepo(t, dir, "add", "-A")
gitInRepo(t, dir, "commit", "--quiet", "-m", "x")
return gitInRepo(t, dir, "rev-parse", "HEAD")
}
// aRepositoryWithARelease is a repository whose main holds mainFiles and, when releaseFiles is not nil, a release
// branch over them holding releaseFiles. It answers the repository, main's head and the release branch's head.
func aRepositoryWithARelease(t *testing.T, mainFiles, releaseFiles map[string]string) (string, string, string) {
t.Helper()
dir := t.TempDir()
gitInRepo(t, dir, "init", "--quiet", "-b", "main")
main := commitFiles(t, dir, mainFiles)
if releaseFiles == nil {
return dir, main, ""
}
gitInRepo(t, dir, "checkout", "--quiet", "-b", releaseBranch)
head := commitFiles(t, dir, releaseFiles)
gitInRepo(t, dir, "checkout", "--quiet", "main")
return dir, main, head
}
// aJudgeOf is a controller that judges as told: its gate passes, saying who it is and the word of the node-engine
// validator it vendors — the one it was built with.
func aJudgeOf(who string) map[string]string {
return map[string]string{
"go.mod": "module example.org/judge\n\ngo 1.22\n\nrequire github.com/novox/mesh-host v0.0.0\n",
"vendor/modules.txt": "# github.com/novox/mesh-host v0.0.0\n## explicit; go 1.22\ngithub.com/novox/mesh-host/validate\n",
"vendor/github.com/novox/mesh-host/validate/v.go": "package validate\n\nconst Word = \"the vendored validator\"\n",
"vendor/github.com/novox/mesh-host/internal/declaration/d.go": "package declaration\n",
"cmd/mesh-controller/main.go": `package main
import (
"fmt"
"os"
"github.com/novox/mesh-host/validate"
)
func main() {
if len(os.Args) == 2 && os.Args[1] == "merge-gate" {
fmt.Println("usage: merge-gate --facts <file> --store <postgres>")
os.Exit(2)
}
fmt.Printf("{\"verdict\":\"pass\",\"summary\":\"%s with %s\"}\n", "` + who + `", validate.Word)
}
`,
}
}
// aHostSaying is the node-engine's validator, saying a word.
func aHostSaying(word string) map[string]string {
return map[string]string{
"validate/v.go": "package validate\n\nconst Word = \"" + word + "\"\n",
"internal/declaration/d.go": "package declaration\n",
"validate/v_test.go": "package validate\n",
"internal/declaration/x_test.go": "package declaration\n",
}
}
// aRuntimeThatBuildsTheJudge puts a container runtime on PATH that runs every container's command on this machine,
// in the directory and with the environment the check gives it — the judge's build too — and a store and a bus that
// answer. It answers the file each `go` the check runs is written to.
func aRuntimeThatBuildsTheJudge(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("no Go toolchain on this machine to build a judge with")
}
bus, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { bus.Close() })
go func() {
for {
c, err := bus.Accept()
if err != nil {
return
}
c.Close()
}
}()
dir := t.TempDir()
built := filepath.Join(dir, "built")
runtime := `#!/bin/sh
cmd="$1"; shift
case "$cmd" in
ps|rm|exec) exit 0 ;;
port) echo "` + bus.Addr().String() + `"; exit 0 ;;
run)
workdir=""; detached=""
while [ $# -gt 0 ]; do
case "$1" in
-d) detached=1; shift ;;
--rm) shift ;;
--workdir) workdir="$2"; shift 2 ;;
--env) export "$2"; shift 2 ;;
--network|--volume|--user|--label|--name|-p|-e) shift 2 ;;
*) break ;;
esac
done
if [ -n "$detached" ]; then echo a-container; exit 0; fi
shift
if [ "$1" = go ]; then echo "$*" >> "` + built + `"; fi
cd "$workdir" && exec "$@" ;;
esac
exit 1
`
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(runtime), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("GOTOOLCHAIN", "local")
return built
}
// builds is how many times the check ran go to build a judge.
func builds(t *testing.T, file string) int {
t.Helper()
body, err := os.ReadFile(file)
if err != nil {
return 0
}
return strings.Count(string(body), "go build")
}
// theReleasedMesh is the core beside a release-branch check: a controller whose main and release branch judge
// differently, a node-engine with a validator of each, and a lab with no release branch.
func theReleasedMesh(t *testing.T) (map[string]Beside, string, string, string) {
t.Helper()
controller, _, controllerRelease := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
aJudgeOf("the release branch's controller"))
host, _, hostRelease := aRepositoryWithARelease(t, aHostSaying("main's validator"),
aHostSaying("the release branch's validator"))
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{"README": "the lab"}, nil)
return map[string]Beside{
"mesh-controller": {Repository: controller, Ref: releaseBranch},
"mesh-controller-main": {Repository: controller, Ref: "main"},
"mesh-host": {Repository: host, Ref: releaseBranch, Else: "main"},
"mesh-lab": {Repository: lab, Ref: releaseBranch, Else: "main"},
}, controllerRelease, hostRelease, labMain
}
// aPullRequestInto is a repository whose pull request's head is cut from base, with a change.
func aPullRequestInto(t *testing.T, files map[string]string, base string, change map[string]string) (string, string) {
t.Helper()
repo, _, _ := aRepositoryWithARelease(t, files, map[string]string{"released": "r"})
gitInRepo(t, repo, "checkout", "--quiet", "-b", "change", base)
head := commitFiles(t, repo, change)
gitInRepo(t, repo, "checkout", "--quiet", "main")
return repo, head
}
func TestACataloguePullRequestIntoAReleaseBranchIsJudgedByThatBranchsController(t *testing.T) {
built := aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
beside, controllerRelease, hostRelease, labMain := theReleasedMesh(t)
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
cache := t.TempDir()
check := func(id string) CheckVerdict {
t.Helper()
v, err := Check(t.Context(), Command, CheckSpec{ID: id, Repository: catalogue, Ref: head, Owner: "novox",
Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch, JudgeCache: cache,
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside, Toolchain: "golang"},
t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
return v
}
v := check("check-704-catalogue")
// The release branch's controller judged, with mesh-host's validator of the release branch.
if v.Gate == nil || v.Gate.Verdict != "pass" ||
!strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
t.Fatalf("the gate was not judged by the release branch's controller with its node-engine's validator: %+v\n%s",
v.Gate, v.Report)
}
// It names the release branch, the judge's commit, and each repository read beside it at its branch and commit.
mark := "[release " + releaseBranch + ", judged by its controller " + controllerRelease[:12] + "] "
if !strings.HasPrefix(v.Gate.Summary, mark) || !strings.HasPrefix(v.Repo.Summary, mark) {
t.Fatalf("the statuses do not name the release branch and its judge first:\n%s\n%s", v.Gate.Summary, v.Repo.Summary)
}
if v.Release != releaseBranch || v.JudgedBy != controllerRelease[:12] {
t.Fatalf("the verdict names release %q judged by %q, not %s by %s", v.Release, v.JudgedBy, releaseBranch,
controllerRelease[:12])
}
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
t.Fatalf("mesh-host was read at %q (%s), not its release branch at %s", v.BesideAt["mesh-host"],
v.Beside["mesh-host"], hostRelease[:12])
}
// A repository with no release branch is read at its trunk, and says so.
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] {
t.Fatalf("the lab, with no release branch, was read at %q (%s), not main at %s", v.BesideAt["mesh-lab"],
v.Beside["mesh-lab"], labMain[:12])
}
if !strings.Contains(v.Report, "read beside it: ") || !strings.Contains(v.Report, "mesh-host "+releaseBranch+" at "+hostRelease[:12]) {
t.Fatalf("the report does not say what was read beside it:\n%s", v.Report)
}
if n := builds(t, built); n != 1 {
t.Fatalf("the judge was built %d times for one check", n)
}
// **Built once per release-branch head**: a second check while that head stands takes the judge kept.
v = check("check-704-catalogue-again")
if !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
t.Fatalf("the kept judge did not judge: %+v\n%s", v.Gate, v.Report)
}
if n := builds(t, built); n != 1 {
t.Fatalf("the judge of the same release-branch head was built again: %d builds", n)
}
}
func TestAControllerPullRequestIntoAReleaseBranchReadsTheNodeEngineAtThatBranch(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
beside, _, hostRelease, _ := theReleasedMesh(t)
delete(beside, "mesh-controller")
controller, head := aPullRequestInto(t, aJudgeOf("the change's controller"), releaseBranch,
map[string]string{"cmd/x.txt": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-controller", Repository: controller, Ref: head,
Owner: "novox", Repo: "mesh-controller", Number: 315, Base: releaseBranch, Release: releaseBranch,
JudgeCache: t.TempDir(), Paths: []string{"cmd/x.txt"}, Modules: []string{"mesh-controller"}, Judge: "self",
Beside: beside, Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the change's controller with the vendored validator") {
t.Fatalf("a controller change into a release branch did not judge itself: %+v\n%s", v.Gate, v.Report)
}
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
t.Fatalf("mesh-host was read beside it at %q (%s), not at the release branch's %s", v.BesideAt["mesh-host"],
v.Beside["mesh-host"], hostRelease[:12])
}
if v.Release != releaseBranch || v.JudgedBy == "" || v.JudgedBy != v.Merge[:12] {
t.Fatalf("the verdict names release %q judged by %q, not its own merge %s", v.Release, v.JudgedBy, v.Merge)
}
}
func TestAPullRequestIntoTheTrunkIsJudgedAsBefore(t *testing.T) {
aRuntimeThatBuildsTheJudge(t)
registry := aFactsRegistry(t)
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
aJudgeOf("the release branch's controller"))
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"),
aHostSaying("the release branch's validator"))
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-trunk", Repository: catalogue, Ref: head,
Owner: "novox", Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: t.TempDir(),
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang",
Beside: map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain},
"mesh-host": {Repository: host, Ref: hostMain}}}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Gate == nil || v.Gate.Summary != "main's controller with the vendored validator" {
t.Fatalf("a trunk pull request was not judged by the controller beside it as before: %+v\n%s", v.Gate, v.Report)
}
if v.Release != "" || v.JudgedBy != "" || v.BesideAt != nil || strings.Contains(v.Report, "release branch") {
t.Fatalf("a trunk pull request's verdict speaks of a release branch: %q %q %v", v.Release, v.JudgedBy, v.BesideAt)
}
if v.Beside["mesh-host"] != hostMain[:12] {
t.Fatalf("mesh-host was read at %s, not the commit asked %s", v.Beside["mesh-host"], hostMain[:12])
}
}
+16
View File
@@ -167,6 +167,12 @@ type CheckRequest struct {
// the controller, judged by itself; JudgeValidator for a change to the node-engine, judged by the // the controller, judged by itself; JudgeValidator for a change to the node-engine, judged by the
// running controller built with the change's validator in place of the one it vendors. // running controller built with the change's validator in place of the one it vendors.
Judge string `json:"judge,omitempty"` Judge string `json:"judge,omitempty"`
// Release is the release branch the pull request merges into (novox/hq ADR 0358): its base, when that is not its
// repository's trunk and mesh-controller has a branch of that name. The gate is then judged by the controller
// built from mesh-controller's release branch — a change to the controller still by itself, a change to the
// node-engine with its own validator — and each repository beside it is read at its branch of that name where it
// has one (CheckedOut.Else otherwise). Empty for every other base, checked as before it.
Release string `json:"release,omitempty"`
// Group names a delivery group whose members' heads are composed together with this one (novox/hq ADR // Group names a delivery group whose members' heads are composed together with this one (novox/hq ADR
// 0239), and Members are those other heads, each cloned beside it and laid over the mesh in turn. The // 0239), and Members are those other heads, each cloned beside it and laid over the mesh in turn. The
// repository's own check is not run for a group: each member's pull request runs its own. // repository's own check is not run for a group: each member's pull request runs its own.
@@ -257,6 +263,10 @@ const (
type CheckedOut struct { type CheckedOut struct {
Repository string `json:"repository"` Repository string `json:"repository"`
Ref string `json:"ref,omitempty"` Ref string `json:"ref,omitempty"`
// Else is the ref read when the repository has no branch Ref (novox/hq ADR 0358 §3): beside a check of a pull
// request into a release branch, Ref is that branch and Else the repository's trunk. Empty: Ref is read, or the
// check cannot run.
Else string `json:"else,omitempty"`
} }
// CheckOutcome is a merge check's verdict. // CheckOutcome is a merge check's verdict.
@@ -281,6 +291,12 @@ type CheckOutcome struct {
Members []CheckedMember `json:"members,omitempty"` Members []CheckedMember `json:"members,omitempty"`
// Built are the builds it made for its merge (novox/hq ADR 0350 §1). // Built are the builds it made for its merge (novox/hq ADR 0350 §1).
Built []CheckBuilt `json:"built,omitempty"` Built []CheckBuilt `json:"built,omitempty"`
// Release is the release branch the check was judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the
// controller that judged its gate, as twelve characters, and BesideAt the branch each repository beside it was
// read at, by directory. Empty from a check of any other base, and from a build seat from before it.
Release string `json:"release,omitempty"`
JudgedBy string `json:"judged-by,omitempty"`
BesideAt map[string]string `json:"beside-at,omitempty"`
} }
// SourceOnSeat names a repository by the seat whose holder serves it and its path there. // SourceOnSeat names a repository by the seat whose holder serves it and its path there.
+6
View File
@@ -293,6 +293,12 @@ type Checked struct {
Trunk string `json:"trunk,omitempty"` Trunk string `json:"trunk,omitempty"`
Beside map[string]string `json:"beside,omitempty"` Beside map[string]string `json:"beside,omitempty"`
Took string `json:"took,omitempty"` Took string `json:"took,omitempty"`
// Release is the release branch the check judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the
// controller that judged its gate (twelve characters), and BesideAt the branch each repository beside it was
// read at, by directory: so a reader sees a release-branch judgement apart from a trunk one. Empty otherwise.
Release string `json:"release,omitempty"`
JudgedBy string `json:"judged-by,omitempty"`
BesideAt map[string]string `json:"beside-at,omitempty"`
// Built are the builds the check made for its merge, each with its reuse key and digests, or why not // Built are the builds the check made for its merge, each with its reuse key and digests, or why not
// (novox/hq ADR 0350 §1): what the walk of the merge takes instead of building. // (novox/hq ADR 0350 §1): what the walk of the merge takes instead of building.
Built []CheckBuilt `json:"built,omitempty"` Built []CheckBuilt `json:"built,omitempty"`