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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user