Publish only a commit on its module's trunk; post a pull request's change plan (hq ADR 0238)
One commit, one plan: a commit off the trunk — a pull request's head, a branch built by hand, a rebuild or replay of one — is for checking. The build seat reads from its clone which branches hold the commit, and the controller records and never registers a build whose commit is not on the branch the module follows (the repository's default for a new one), so nothing off the trunk can be sent. A pull request's check now carries its change plan, computed by the planner: what a merge would build in which order, what each machine would receive, and what is not an ordinary send — the bus step, a module waiting for a person, a provider's consumers.
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user