diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index e6a826a..e22a0ee 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -293,6 +293,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri } result.Against = built.Against result.SourceFingerprint = built.Source + result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches for _, r := range built.Read { result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref}) } diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 86c460e..54d75cb 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/novox/mesh-controller/internal/conditions" "os" + "slices" "strings" "time" @@ -572,6 +573,19 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", result.On, result.Repository, short(result.Commit), err) } + // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's + // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay + // --register` of one — is for checking, and is never a module's version; nothing could then send it. + // The branch the module already follows is its trunk — never the branch a build was asked at, or a + // build of a feature branch by name would make that branch its trunk. + follows := "" + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { + follows = followedBranch(was.Ref) + } + if err := publishable(result, follows); err != nil { + return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, + manifest.Module, short(result.Commit), err) + } // **A build that failed its gate is never registered again** (novox/hq ADR 0236): an outcome heard // twice, or replayed, would otherwise make the build a rollback put back what the module is again, // and the next push would send it. @@ -613,6 +627,34 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu return manifest, kept, nil } +// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published. +var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " + + "never published (ADR 0238) — merge it, and the merge builds it") + +// publishable says whether a build's outcome may become a module's version: its commit on the module's +// trunk, as the build seat read the forge at the build — the branch the module follows when its source +// names one, else its repository's default branch. A build seat that could not say — one older than the +// rule, building its own successor — is let through and said, so the rule can reach the mesh. +func publishable(result link.BuildResult, follows string) error { + if result.Check != nil || result.Checked != nil || result.DryRun { + return errors.New("a check or a dry run is never published") + } + if result.Trunk == "" { + fmt.Printf("%s: the build seat did not say whether %s is on its repository's trunk (it predates ADR 0238); "+ + "registered as before\n", result.ID, short(result.Commit)) + return nil + } + trunk := result.Trunk + if follows != "" { + trunk = follows + } + on := slices.Contains(result.Branches, trunk) || (trunk == result.Trunk && result.OnTrunk) + if !on { + return fmt.Errorf("%w (%s is not on %s)", errOffTheTrunk, short(result.Commit), trunk) + } + return nil +} + // buildAndShow builds and prints the manifest without recording anything. func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error { repository, err := cloneFrom(ctx, source) diff --git a/cmd/mesh-controller/changeplan.go b/cmd/mesh-controller/changeplan.go new file mode 100644 index 0000000..9a86688 --- /dev/null +++ b/cmd/mesh-controller/changeplan.go @@ -0,0 +1,155 @@ +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// The change plan (novox/hq ADR 0238): **one commit, one plan** — what a change does to the mesh, computed +// from its diffset (a repository, the branch it merges into, the commit at hand) by the planner, never by +// a mapping of its own. reachOfMerge answers what moves and what follows it; this adds where each module +// goes — the deploy plan, machine by machine in the build plan's order — and what is not an ordinary +// send. A pull request's check posts it with its verdict; the release a merge makes follows the same +// planner, so the two can be compared. + +// policyOf is a module's upgrade policy, as the controller holds it; false when it cannot be read. +type policyOf func(module string) (inventory.Upgrade, bool) + +// changePlanOf is the change plan of a reach: pure, so it is tested without a store. +func changePlanOf(repository, base, head string, r mergeReach, entries []inventory.Entry, policy policyOf) link.ChangePlan { + p := link.ChangePlan{Repository: repository, Base: base, Head: head, Moved: r.Moved(), Dependents: r.Dependents(), + New: r.Added, Unread: r.Unread, Tiers: r.Plan.Tiers} + byName := map[string]inventory.Entry{} + for _, e := range entries { + byName[e.Manifest.Module] = e + } + machines := map[string]*link.MachinePlan{} + machine := func(name string) *link.MachinePlan { + if machines[name] == nil { + machines[name] = &link.MachinePlan{Machine: name} + } + return machines[name] + } + for _, tier := range r.Plan.Tiers { + for _, name := range tier { + e, held := byName[name] + if !held { + continue + } + u, known := policy(name) + waits := known && !u.RollOut + for _, on := range e.On { + if waits { + machine(on).Waits = append(machine(on).Waits, name) + } else { + machine(on).Receives = append(machine(on).Receives, name) + } + } + switch { + case name == "nats": + p.Steps = append(p.Steps, "a planned bus step: the bus is upgraded by `bus upgrade`, never by an ordinary send") + case waits && len(e.On) > 0: + p.Steps = append(p.Steps, fmt.Sprintf("%s waits for a person: its policy records (%s)", name, + orNone(u.From))) + } + if len(e.Manifest.Provides) > 0 && len(e.On) > 0 { + var offers []string + for _, o := range e.Manifest.Provides { + offers = append(offers, o.Name) + } + p.Steps = append(p.Steps, fmt.Sprintf("%s provides %s: its consumers are sent again after it", + name, strings.Join(offers, ", "))) + } + if e.Manifest.Data != nil && len(e.On) > 0 { + p.Steps = append(p.Steps, fmt.Sprintf("%s keeps data (ADR 0233): what it holds is backed up before it moves", name)) + } + } + } + var names []string + for name := range machines { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + p.Machines = append(p.Machines, *machines[name]) + } + p.Summary = summaryOf(p) + return p +} + +// summaryOf is a change plan in one line: what it builds, where it goes, and whether the bus moves. +func summaryOf(p link.ChangePlan) string { + if len(p.Moved) == 0 && len(p.New) == 0 { + return "builds nothing: the change touches no module of the mesh's graph" + } + var parts []string + what := strings.Join(p.Moved, ", ") + if len(p.Dependents) > 0 { + what += fmt.Sprintf(" (+%d dependent(s))", len(p.Dependents)) + } + if len(p.New) > 0 { + if what != "" { + what += ", " + } + what += "new: " + strings.Join(p.New, ", ") + } + var to []string + for _, m := range p.Machines { + if len(m.Receives) > 0 { + to = append(to, m.Machine) + } + } + if len(to) > 0 { + parts = append(parts, "builds "+what+" → "+strings.Join(to, ", ")) + } else { + parts = append(parts, "builds "+what+", sent nowhere") + } + bus := "no bus step" + for _, s := range p.Steps { + if strings.HasPrefix(s, "a planned bus step") { + bus = "a bus step" + } + } + parts = append(parts, bus) + waiting := 0 + for _, m := range p.Machines { + waiting += len(m.Waits) + } + if waiting > 0 { + parts = append(parts, fmt.Sprintf("%d wait(s) for a person", waiting)) + } + return strings.Join(parts, "; ") +} + +// planText is a change plan as a person reads it, for the pull request's comment. +func planText(p link.ChangePlan) string { + var b strings.Builder + fmt.Fprintf(&b, "change plan of %s at %.8s into %s: %s\n", p.Repository, p.Head, p.Base, p.Summary) + for i, tier := range p.Tiers { + fmt.Fprintf(&b, " tier %d: %s\n", i, strings.Join(tier, ", ")) + } + for _, m := range p.Machines { + line := " " + m.Machine + ": " + if len(m.Receives) > 0 { + line += "receives " + strings.Join(m.Receives, ", ") + } + if len(m.Waits) > 0 { + if len(m.Receives) > 0 { + line += "; " + } + line += "waits for a person: " + strings.Join(m.Waits, ", ") + } + b.WriteString(line + "\n") + } + for _, s := range p.Steps { + b.WriteString(" - " + s + "\n") + } + if len(p.Unread) > 0 { + fmt.Fprintf(&b, " read by no module's build: %s\n", strings.Join(p.Unread, ", ")) + } + return b.String() +} diff --git a/cmd/mesh-controller/changeplan_test.go b/cmd/mesh-controller/changeplan_test.go new file mode 100644 index 0000000..a2632b7 --- /dev/null +++ b/cmd/mesh-controller/changeplan_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// **One commit, one change plan** (novox/hq ADR 0238): the planner's reach, laid out machine by machine in +// the build plan's order, with what is not an ordinary send said — the bus step, a module that waits for a +// person, a provider whose consumers follow it. +func TestAChangePlanSaysWhatEachMachineReceives(t *testing.T) { + const catalogue_ = "http://forge.internal:20000/novox/mesh-catalog.git" + entry := func(name, path string, on ...string) inventory.Entry { + e := fromRepo(name, catalogue_, path) + e.Source.BuiltFrom = "old" + e.On = on + return e + } + nats := entry("nats", "modules/nats", "anchor") + nats.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} + gitea := entry("gitea", "modules/gitea", "anchor") + held := entry("photos", "modules/photos", "anchor", "laptop") + tools := fromRepo("node-tools", "http://forge.internal:20000/novox/mesh-tools.git", "node-tools") + tools.On = []string{"anchor", "laptop"} + entries := []inventory.Entry{nats, gitea, held, tools} + edges := []inventory.Edge{{From: "node-tools", To: "nats", Kind: inventory.EdgeStandsOn}} + policy := func(module string) (inventory.Upgrade, bool) { + if module == "photos" { + return inventory.Upgrade{RollOut: false, From: "a person"}, true + } + return inventory.Upgrade{RollOut: true}, true + } + plan := func(paths ...string) link.ChangePlan { + m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "head", Paths: paths} + return changePlanOf("novox/mesh-catalog", "main", "head", reachOfMerge(m, entries, nil, edges), entries, policy) + } + + p := plan("modules/gitea/index.ts") + if p.Summary != "builds gitea → anchor; no bus step" { + t.Errorf("one module's change reads %q", p.Summary) + } + + p = plan("modules/nats/Dockerfile", "modules/photos/x.js") + if !strings.Contains(p.Summary, "a bus step") || !strings.Contains(p.Summary, "2 wait(s) for a person") || + !strings.Contains(p.Summary, "(+1 dependent(s))") { + t.Errorf("the bus and a held module read %q", p.Summary) + } + got := map[string]string{} + for _, m := range p.Machines { + got[m.Machine] = strings.Join(m.Receives, ",") + "|" + strings.Join(m.Waits, ",") + } + // The bus first, what stands on it after: the build plan's order, per machine. + if got["anchor"] != "nats,node-tools|photos" || got["laptop"] != "node-tools|photos" { + t.Errorf("the deploy plan reads %v", got) + } + text := strings.Join(p.Steps, "\n") + for _, want := range []string{"a planned bus step", "photos waits for a person", "nats provides mesh-bus"} { + if !strings.Contains(text, want) { + t.Errorf("the steps do not say %q:\n%s", want, text) + } + } + + p = plan("merge-check.sh") + if !strings.HasPrefix(p.Summary, "builds nothing") || len(p.Machines) != 0 || strings.Join(p.Unread, ",") != "merge-check.sh" { + t.Errorf("a root file's plan reads %+v", p) + } + if !strings.Contains(planText(p), "read by no module's build: merge-check.sh") { + t.Errorf("the plan's text does not say why nothing is built:\n%s", planText(p)) + } +} diff --git a/cmd/mesh-controller/checks.go b/cmd/mesh-controller/checks.go index 6a7c5e7..ee457a6 100644 --- a/cmd/mesh-controller/checks.go +++ b/cmd/mesh-controller/checks.go @@ -74,6 +74,8 @@ type checkScope struct { Judge string // From is a module built from the repository, for how the mesh clones it; nil when none is. From *inventory.Entry + // Reach is the planner's whole answer, which the change plan is made from. + Reach mergeReach } func (s checkScope) gated() bool { return len(s.Modules)+len(s.New) > 0 } @@ -88,7 +90,7 @@ func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][] ModuleDirsSaid: p.ModuleDirsSaid} r := reachOfMerge(m, entries, read, edges) s := checkScope{Modules: r.Moved(), Dependents: r.Dependents(), New: r.Added, Unread: r.Unread, - Width: len(r.Plan.Modules), Tiers: len(r.Plan.Tiers)} + Width: len(r.Plan.Modules), Tiers: len(r.Plan.Tiers), Reach: r} for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) { s.Manifests = append(s.Manifests, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile)) switch e.Manifest.Module { @@ -150,9 +152,16 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error { return err } scope := pullScope(p, entries, read, edges) + // The change plan of the commit at hand (ADR 0238): what a merge of it would build and send, posted + // with the verdict whatever the verdict is. + plan := changePlanOf(p.Owner+"/"+p.Repo, p.Base, p.Commit, scope.Reach, entries, func(module string) (inventory.Upgrade, bool) { + u, err := inv.UpgradeOf(ctx, module) + return u, err == nil + }) + fmt.Print(planText(plan)) direct := link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit, ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched, - Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}} + Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan} switch { case !scope.gated() && !scope.Mesh: fmt.Printf("%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n", @@ -169,6 +178,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error { if err != nil { return err } + request.Check.Plan = &plan seat := buildSeatHeld(ctx) ask, err := askOverOn(seat) if err != nil { @@ -326,6 +336,9 @@ func checkedOf(result link.BuildResult) link.Checked { if result.Checked != nil && c.Gate != nil && len(c.Gate.Dependents) == 0 { c.Gate.Dependents = result.Checked.Dependents } + if result.Checked != nil { + c.Plan = result.Checked.Plan + } for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} { if l != nil && l.Verdict == "" { l.Verdict = "error" diff --git a/cmd/mesh-controller/checks_test.go b/cmd/mesh-controller/checks_test.go index 2aee808..7e0bc94 100644 --- a/cmd/mesh-controller/checks_test.go +++ b/cmd/mesh-controller/checks_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "strings" "testing" @@ -169,3 +170,43 @@ func TestAPullRequestReachesWhatAMergeOfItWouldPlan(t *testing.T) { t.Fatalf("a change to the source gitea packages reaches %v (manifests %v)", s.Modules, s.Manifests) } } + +// **Only a commit on the trunk is published** (novox/hq ADR 0238): a build of a commit off its repository's +// default branch — a pull request's head, a branch built by hand, a rebuild or replay of one — is recorded +// and never registered, so nothing can send it; a check or a dry run never is either. +func TestABuildOffTheTrunkIsNeverPublished(t *testing.T) { + on := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main", OnTrunk: true} + if err := publishable(on, ""); err != nil { + t.Errorf("a build on the trunk was refused: %v", err) + } + off := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main"} + if err := publishable(off, ""); !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "main") { + t.Errorf("a build off the trunk was let through: %v", err) + } + for _, r := range []link.BuildResult{ + {ID: "c", Trunk: "main", OnTrunk: true, Check: &link.CheckOutcome{Verdict: "pass"}}, + {ID: "d", Trunk: "main", OnTrunk: true, Checked: &link.CheckRequest{}}, + {ID: "e", Trunk: "main", OnTrunk: true, DryRun: true}, + } { + if publishable(r, "") == nil { + t.Errorf("%s, a check or a dry run, was publishable", r.ID) + } + } + // A module that follows a branch other than the default: that branch is its trunk, and the default + // is not. + follows := link.BuildResult{ID: "g", Commit: "abc", Trunk: "master", Branches: []string{"nox-mesh"}} + if err := publishable(follows, "nox-mesh"); err != nil { + t.Errorf("a commit on the branch a module follows was refused: %v", err) + } + if err := publishable(link.BuildResult{ID: "h", Commit: "abc", Trunk: "master", OnTrunk: true, + Branches: []string{"master"}}, "nox-mesh"); err == nil { + t.Error("a commit on the default but not on the branch the module follows was published") + } + if err := publishable(link.BuildResult{ID: "i", Commit: "abc", Trunk: "main", Branches: []string{"feat/x"}}, ""); err == nil { + t.Error("a feature branch's commit was published") + } + // A build seat older than the rule says nothing: let through and said, so the rule can reach the mesh. + if err := publishable(link.BuildResult{ID: "f", Commit: "abc"}, ""); err != nil { + t.Errorf("a build seat that said nothing was refused: %v", err) + } +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 9dabaa3..0484a40 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -68,6 +68,15 @@ type Result struct { // change to this module (novox/hq 04-ISSUES/131). Read []catalogue.ArtifactContext + // Trunk is the repository's default branch as the forge holds it at the build, and OnTrunk whether + // the commit built is reachable from it (novox/hq ADR 0238): a commit off the trunk is checked, + // never published. Trunk empty is "could not be said". + Trunk string + OnTrunk bool + // Branches are every branch of the forge the commit is on: a module may follow a branch other than + // the default, and that branch is its trunk. + Branches []string + // Source is the build's source fingerprint (source.go): what it was made from — the module's tree, // the contexts' trees, the bases and toolchains by digest — hashed. Empty where the source does not // pin the build. Two builds with one fingerprint are one build, whatever digests they made @@ -144,6 +153,16 @@ func Build(ctx context.Context, run Runner, publish Publisher, } commit = strings.TrimSpace(commit) say("commit", "%s", short(commit)) + // **Whether the commit is on the trunk** (novox/hq ADR 0238): only a commit on the repository's own + // default branch is published. Read from the clone just made — the forge's own word on which branch + // is its default and what it holds now — and said with the outcome, so the controller refuses to + // register a build of a commit off it. + trunk, onTrunk := trunkOf(ctx, run, tree, commit) + branches := branchesHolding(ctx, run, tree, commit) + if trunk != "" { + say("trunk", "%s is %son %s; on %s", short(commit), map[bool]string{true: "", false: "NOT "}[onTrunk], trunk, + orNoBranch(branches)) + } // A module is a repository and a path within it (novox/hq ADR 0069). The ordinary case is an // empty path, meaning the repository's root; a repository holding several modules names each @@ -250,7 +269,50 @@ func Build(ctx context.Context, run Runner, publish Publisher, say("source", "no source fingerprint: %s", orNoTree(src.unpinned)) } return Result{Manifest: resolved, Commit: commit, Built: built, - Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint}, nil + Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, + Trunk: trunk, OnTrunk: onTrunk, Branches: branches}, nil +} + +// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`. +func branchesHolding(ctx context.Context, run Runner, clone, commit string) []string { + out, err := run(ctx, clone, "git", "branch", "--remotes", "--format=%(refname:short)", "--contains", commit) + if err != nil { + // empty-on-error: no branch said is the commit on none, which refuses its registration — never a pass + return nil + } + var branches []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if b, ok := strings.CutPrefix(line, "origin/"); ok && b != "HEAD" && b != "" { + branches = append(branches, b) + } + } + sort.Strings(branches) + return branches +} + +func orNoBranch(branches []string) string { + if len(branches) == 0 { + return "no branch" + } + return strings.Join(branches, ", ") +} + +// trunkOf is a fresh clone's trunk — the branch the forge names its default, as `origin/HEAD` says — and +// whether a commit is reachable from it. Empty when the clone does not say, which is "not known", never +// "on it". +func trunkOf(ctx context.Context, run Runner, clone, commit string) (string, bool) { + head, err := run(ctx, clone, "git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") + if err != nil { + return "", false + } + remote := strings.TrimSpace(head) + trunk := strings.TrimPrefix(remote, "origin/") + if trunk == "" || trunk == remote { + return "", false + } + _, err = run(ctx, clone, "git", "merge-base", "--is-ancestor", commit, remote) + return trunk, err == nil } // orNoTree is why a build has no source fingerprint, for its log. diff --git a/internal/builder/check_test.go b/internal/builder/check_test.go index b7eb233..768c4f4 100644 --- a/internal/builder/check_test.go +++ b/internal/builder/check_test.go @@ -330,3 +330,47 @@ func TestTheGateRunsWhenTheGraphIsTouchedBesideTheRepositorysOwnCheck(t *testing t.Fatalf("a controller that does not build judged itself %+v", v.Gate) } } + +// **Only a commit on the trunk is published** (novox/hq ADR 0238): the build seat reads, from the clone it +// just made, the branch the forge names its default and whether the commit built is on it. +func TestABuildSaysWhetherItsCommitIsOnTheTrunk(t *testing.T) { + repo, onMain := aCheckedRepository(t, map[string]string{"module.json": `{"module":"x","version":"1"}`}) + git := func(dir string, args ...string) string { + 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)) + } + git(repo, "checkout", "--quiet", "-b", "feature") + if err := os.WriteFile(filepath.Join(repo, "x"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + git(repo, "add", "-A") + git(repo, "commit", "--quiet", "-m", "off the trunk") + offMain := git(repo, "rev-parse", "HEAD") + git(repo, "checkout", "--quiet", "main") + + clone := filepath.Join(t.TempDir(), "clone") + git(filepath.Dir(clone), "clone", "--quiet", repo, clone) + if trunk, on := trunkOf(t.Context(), Command, clone, onMain); trunk != "main" || !on { + t.Errorf("a commit on main reads as on %q: %v", trunk, on) + } + if trunk, on := trunkOf(t.Context(), Command, clone, offMain); trunk != "main" || on { + t.Errorf("a feature branch's commit reads as on %q: %v", trunk, on) + } + if got := strings.Join(branchesHolding(t.Context(), Command, clone, offMain), ","); got != "feature" { + t.Errorf("the feature branch's commit is said to be on %q", got) + } + if got := strings.Join(branchesHolding(t.Context(), Command, clone, onMain), ","); got != "feature,main" { + t.Errorf("main's commit is said to be on %q", got) + } + // A tree that says no trunk is not known — never read as on it. + if trunk, on := trunkOf(t.Context(), Command, repo, onMain); trunk != "" || on { + t.Errorf("a repository with no origin reads as trunk %q, on %v", trunk, on) + } +} diff --git a/internal/link/build.go b/internal/link/build.go index 9203b2f..ca58beb 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -114,6 +114,8 @@ type CheckRequest struct { // Dependents are the modules a merge would build after Modules because they stand on them: the // planner's dependency walk, said on the pull request. Dependents []string `json:"dependents,omitempty"` + // Plan is the change plan of the commit checked, computed by the controller and echoed with the outcome. + Plan *ChangePlan `json:"plan,omitempty"` // Manifests are the touched modules' manifests in the change's tree, by path from its root: what the // gate puts through `module check`. Manifests []string `json:"manifests,omitempty"` @@ -203,6 +205,15 @@ type BuildResult struct { // manifest the mesh keeps says nothing about it (novox/hq 04-ISSUES/131). Read []ReadRepository `json:"read,omitempty"` + // Trunk is the repository's default branch at the build, and OnTrunk whether the commit built is on it + // (novox/hq ADR 0238): **only a commit on the trunk is published** — the controller refuses to register + // a build of one off it. Empty Trunk is a build seat that could not say, or predates the rule. + Trunk string `json:"trunk,omitempty"` + OnTrunk bool `json:"on-trunk,omitempty"` + // Branches are every branch the commit is on: the trunk of a module that follows a branch other than + // the repository's default is the branch it follows. + Branches []string `json:"branches,omitempty"` + // SourceFingerprint is what the build was made from, hashed (novox/hq issue 280): the module's // tree at the commit, the trees of the contexts it read, its bases and toolchains by digest. Two // builds with one fingerprint are one build, however their digests differ — an image is not diff --git a/internal/link/events.go b/internal/link/events.go index a7a6fcb..d70f62a 100644 --- a/internal/link/events.go +++ b/internal/link/events.go @@ -258,6 +258,41 @@ type Checked struct { // RepoCheck is nil when the repository is not the mesh's and touches nothing of it: nothing is said. Gate *CheckLayer `json:"gate,omitempty"` RepoCheck *CheckLayer `json:"repo-check,omitempty"` + // Plan is the change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build + // and send, posted with the verdict. + Plan *ChangePlan `json:"plan,omitempty"` +} + +// ChangePlan is what a change does to the mesh, computed from its diffset — a repository, the branch it +// merges into and the commit at hand — by the planner (novox/hq ADR 0238): **one commit, one plan**, the +// object a pull request's check posts, the release follows and a person reads. +type ChangePlan struct { + Repository string `json:"repository"` + Base string `json:"base"` + Head string `json:"head"` + // Moved are the modules a merge moves itself, Dependents those built after them because they stand + // on them, New the directories it adds a module in, and Unread the changed files no build reads. + Moved []string `json:"moved,omitempty"` + Dependents []string `json:"dependents,omitempty"` + New []string `json:"new,omitempty"` + Unread []string `json:"unread,omitempty"` + // Tiers are the build plan's order: each tier built after the one before. + Tiers [][]string `json:"tiers,omitempty"` + // Machines are the deploy plan: what each machine is sent, in the build plan's order, and what waits + // there for a person. + Machines []MachinePlan `json:"machines,omitempty"` + // Steps are what is not an ordinary send: a planned bus step, a provider whose consumers are sent again, + // a module that waits for a person, a module that keeps data. + Steps []string `json:"steps,omitempty"` + // Summary is the plan in one line, for a commit status. + Summary string `json:"summary"` +} + +// MachinePlan is one machine's part of a change plan. +type MachinePlan struct { + Machine string `json:"machine"` + Receives []string `json:"receives,omitempty"` + Waits []string `json:"waits,omitempty"` } // CheckLayer is one layer of a merge check, judged. diff --git a/mesh-controller b/mesh-controller index 5ea822b..b8fa8cd 100755 Binary files a/mesh-controller and b/mesh-controller differ