Compare commits

...
Author SHA1 Message Date
jschoubben af4e4e60a3 Carry mesh package versions in build asks and fingerprints
ADR 0573 §2 has every module build against the latest published version of
each mesh package it uses, chosen by the planner and written by the build,
never pinned by hand (issue 1585, S4). The ask, the fetch request and the
merge preview now carry the packages and their versions, and the new-form
fingerprint takes each version, so a package's trunk merge changes every
dependent's fingerprint. A module using no package keeps its fingerprint.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 01:57:26 +01:00
14 changed files with 356 additions and 22 deletions
+19 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"path" "path"
"slices" "slices"
"sort" "sort"
@@ -42,14 +43,30 @@ type AskSteps struct {
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer. // RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) { func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
if ask.Build != nil { if ask.Build != nil {
return steps.build(ctx, *ask.Build) job := *ask.Build
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.build(ctx, job)
} }
if ask.Check != nil { if ask.Check != nil {
return steps.check(ctx, *ask.Check) job := *ask.Check
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.check(ctx, job)
} }
return nil, Refuse("the ask %s holds no job", ask.ID) return nil, Refuse("the ask %s holds no job", ask.ID)
} }
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
// fetches nothing, is left as it is.
func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any {
if len(packages) == 0 || fetch == nil {
return fetch
}
out := maps.Clone(fetch)
out["packages"] = maps.Clone(packages)
return out
}
func raw(v any) (json.RawMessage, error) { func raw(v any) (json.RawMessage, error) {
if r, ok := v.(json.RawMessage); ok { if r, ok := v.(json.RawMessage); ok {
return r, nil return r, nil
+12 -6
View File
@@ -74,15 +74,21 @@ func TestRunAskBuilds(t *testing.T) {
steps := r.steps() steps := r.steps()
var released []string var released []string
steps.Release = func(ws string) error { released = append(released, ws); return nil } steps.Release = func(ws string) error { released = append(released, ws); return nil }
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true, // Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were.
a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})) Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
out, err := RunAsk(context.Background(), steps, a)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, put := a.Build.Fetch["packages"]; put {
t.Error("the ask's fetch arguments were changed")
}
got := strings.Join(r.calls, "\n") got := strings.Join(r.calls, "\n")
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"} want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
fetch {"commit":"` + runCommit + `","repository":"novox/x"} fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"}
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"} build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}` build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if got != want { if got != want {
@@ -91,9 +97,9 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if len(released) != 1 || released[0] != "/w/1" { if len(released) != 1 || released[0] != "/w/1" {
t.Errorf("released %v, want the one workspace", released) t.Errorf("released %v, want the one workspace", released)
} }
a := out.(delivery.BuildAnswer) answer := out.(delivery.BuildAnswer)
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" { if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" {
t.Fatalf("answer %+v", a) t.Fatalf("answer %+v", answer)
} }
} }
+48
View File
@@ -2,9 +2,12 @@ package buildstep
import ( import (
"fmt" "fmt"
"maps"
"path" "path"
"regexp" "regexp"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
var ( var (
@@ -27,6 +30,10 @@ type FetchRequest struct {
Base string `json:"base,omitempty"` Base string `json:"base,omitempty"`
Dir string `json:"dir,omitempty"` Dir string `json:"dir,omitempty"`
Paths []string `json:"paths,omitempty"` Paths []string `json:"paths,omitempty"`
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
// the repository names, checksum-verified, and names them in its log.
Packages delivery.PackageVersions `json:"packages,omitempty"`
} }
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input. // FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
@@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any {
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"}, "dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"}, "description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
} }
} }
@@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any {
if len(r.Paths) > 0 { if len(r.Paths) > 0 {
args["paths"] = append([]string(nil), r.Paths...) args["paths"] = append([]string(nil), r.Paths...)
} }
if len(r.Packages) > 0 {
args["packages"] = map[string]any{}
for p, v := range r.Packages {
args["packages"].(map[string]any)[p] = v
}
}
return args return args
} }
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
default: default:
return FetchRequest{}, fmt.Errorf("paths is a list of strings") return FetchRequest{}, fmt.Errorf("paths is a list of strings")
} }
packages, err := PackagesArg(args["packages"])
if err != nil {
return FetchRequest{}, err
}
r.Packages = packages
return r, r.Check() return r, r.Check()
} }
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
// nil for none.
func PackagesArg(v any) (delivery.PackageVersions, error) {
out := delivery.PackageVersions{}
switch v := v.(type) {
case nil:
return nil, nil
case delivery.PackageVersions:
maps.Copy(out, v)
case map[string]string:
maps.Copy(out, v)
case map[string]any:
for p, version := range v {
s, ok := version.(string)
if !ok {
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
out[p] = s
}
default:
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
if len(out) == 0 {
return nil, nil
}
return out, out.Check()
}
// Check is what ParseFetchRequest refuses. // Check is what ParseFetchRequest refuses.
func (r FetchRequest) Check() error { func (r FetchRequest) Check() error {
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
+9 -2
View File
@@ -10,11 +10,15 @@ import (
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495 // from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
// clause 1). The request survives the trip through a tool call's arguments. // clause 1). The request survives the trip through a tool call's arguments.
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head", req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
Dir: ".", Paths: []string{"package.json", "src"}} Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
args := req.Args() args := req.Args()
// A tool call's arguments arrive as JSON: lists of any. // A tool call's arguments arrive as JSON: lists of any, objects of any.
args["paths"] = []any{"package.json", "src"} args["paths"] = []any{"package.json", "src"}
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
}
got, err := ParseFetchRequest(args) got, err := ParseFetchRequest(args)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"}, {map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"}, {map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"}, {map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
} { } {
args := map[string]any{} args := map[string]any{}
for k, v := range ok { for k, v := range ok {
+5
View File
@@ -25,6 +25,8 @@ type ModuleTree struct {
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree // Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise. // is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"` Paths map[string]string `json:"paths,omitempty"`
// Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2).
Packages []string `json:"packages,omitempty"`
} }
var lockFiles = []string{"go.sum", "package-lock.json"} var lockFiles = []string{"go.sum", "package-lock.json"}
@@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
mt.Locks = map[string]string{} mt.Locks = map[string]string{}
} }
mt.Locks[lock] = sha256Hex(raw) mt.Locks[lock] = sha256Hex(raw)
if lock == "go.sum" {
mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2)
}
} }
} }
trees[mdir] = mt trees[mdir] = mt
+5 -3
View File
@@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
writeTreeFile(t, root, "module.json", `{"root":1}`) writeTreeFile(t, root, "module.json", `{"root":1}`)
writeTreeFile(t, root, "go.sum", "rootsum") writeTreeFile(t, root, "go.sum", "rootsum")
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`) writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
writeTreeFile(t, root, "modules/a/go.sum", "asum") asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n"
writeTreeFile(t, root, "modules/a/go.sum", asum)
writeTreeFile(t, root, "modules/a/package-lock.json", "alock") writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`) writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
merge := strings.Repeat("c", 40) merge := strings.Repeat("c", 40)
@@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
want := map[string]ModuleTree{ want := map[string]ModuleTree{
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}}, ".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}}, "modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf(asum), "package-lock.json": hexOf("alock")},
Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)}, "modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
} }
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
+24 -3
View File
@@ -11,6 +11,10 @@ import (
// SourcePrefix2 opens every source fingerprint of the new form. // SourcePrefix2 opens every source fingerprint of the new form.
const SourcePrefix2 = "src2:" const SourcePrefix2 = "src2:"
// NoPackageVersion opens the reason a module has no fingerprint while a mesh package it uses has no published version
// the indexer holds (novox/hq ADR 0573 §2); the packages follow, by module path.
const NoPackageVersion = "no published version of the package "
// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the // Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the
// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the // artifact's language, the installed toolchain's version and the build module's recipe version, in place of the
// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only. // toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only.
@@ -37,15 +41,19 @@ type SourceInputs2 struct {
Contexts map[string]string Contexts map[string]string
Locks map[string]string Locks map[string]string
Toolchains map[string]Toolchain2 Toolchains map[string]Toolchain2
Unpinned string // Packages are the version of each mesh package the module uses, by package (novox/hq ADR 0573 §2): a package with
// no version gives no fingerprint. None leaves the fingerprint as it was before packages entered it.
Packages map[string]string
Unpinned string
} }
// SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs' // SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs'
// lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is // lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is
// empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order // empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order
// of maps and a base given twice change nothing. The lines are "module <m>", "tree <t>" and "builder <fp>", then // of maps and a base given twice change nothing. The lines are "module <m>", "tree <t>" and "builder <fp>", then
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>" and "toolchain <artifact> <language> // "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>", "toolchain <artifact> <language>
// <version> <recipe>", sorted, all joined by a newline. // <version> <recipe>" and "package <package> <version>", sorted, all joined by a newline. A module that uses no mesh
// package has no package line, so its fingerprint is the one it had before packages entered (novox/hq ADR 0573 §2).
func SourceFingerprint2(in SourceInputs2) (fp, none string) { func SourceFingerprint2(in SourceInputs2) (fp, none string) {
if in.Unpinned != "" { if in.Unpinned != "" {
return "", in.Unpinned return "", in.Unpinned
@@ -58,6 +66,16 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
return "", "no lock" return "", "no lock"
} }
} }
var unpublished []string
for p, v := range in.Packages {
if v == "" {
unpublished = append(unpublished, p)
}
}
if len(unpublished) > 0 {
sort.Strings(unpublished)
return "", NoPackageVersion + strings.Join(unpublished, ", ")
}
lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder} lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder}
bases := map[string]bool{} bases := map[string]bool{}
for _, b := range in.Bases { for _, b := range in.Bases {
@@ -75,6 +93,9 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
for a, t := range in.Toolchains { for a, t := range in.Toolchains {
lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe) lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe)
} }
for p, v := range in.Packages {
lines = append(lines, "package "+p+" "+v)
}
sort.Strings(lines[3:]) sort.Strings(lines[3:])
sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return SourcePrefix2 + hex.EncodeToString(sum[:]), "" return SourcePrefix2 + hex.EncodeToString(sum[:]), ""
+14
View File
@@ -62,6 +62,9 @@ func TestFingerprint2Changes(t *testing.T) {
"a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" }, "a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" },
"a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" }, "a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" },
"a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} }, "a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} },
// Issue 1585 (ADR 0573 §2): a package's new published version, and a package used at all.
"a package added": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c1"} },
"a package version": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c2"} },
} { } {
in := inputs2() in := inputs2()
f(&in) f(&in)
@@ -111,6 +114,17 @@ func TestFingerprint2None(t *testing.T) {
if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" { if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" {
t.Errorf("no tree: fp = %q, none = %q", fp, none) t.Errorf("no tree: fp = %q, none = %q", fp, none)
} }
// Issue 1585: a package with no published version gives none, naming it; an empty set of packages changes nothing.
in = inputs2()
in.Packages = map[string]string{"git.novox.be/novox/b/go": "", "git.novox.be/novox/a/go": "", "git.novox.be/novox/c/go": "c1"}
if fp, none := SourceFingerprint2(in); fp != "" || none != NoPackageVersion+"git.novox.be/novox/a/go, git.novox.be/novox/b/go" {
t.Errorf("no package version: fp = %q, none = %q", fp, none)
}
in = inputs2()
in.Packages = map[string]string{}
if fp, _ := SourceFingerprint2(in); fp != mustFP2(t, inputs2()) {
t.Errorf("no package changes the fingerprint: %s", fp)
}
in = inputs2() in = inputs2()
in.Tree, in.Unpinned = "", "a context is a Go program's build source" in.Tree, in.Unpinned = "", "a context is a Go program's build source"
if _, none := SourceFingerprint2(in); none != in.Unpinned { if _, none := SourceFingerprint2(in); none != in.Unpinned {
+32
View File
@@ -1,6 +1,7 @@
package delivery package delivery
import ( import (
"reflect"
"strings" "strings"
"testing" "testing"
) )
@@ -163,4 +164,35 @@ func TestModuleKeepsInputs2(t *testing.T) {
if _, err := EncodeModule(m); err == nil { if _, err := EncodeModule(m); err == nil {
t.Error("Inputs2 without a manifest digest was encoded") t.Error("Inputs2 without a manifest digest was encoded")
} }
// Issue 1585 (ADR 0573 §2): the mesh packages a go.sum names, those of other repositories, and the latest published
// version of each: the trunk commit of the deepest module of its repository whose directory holds it.
sum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:a=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:b=\n" +
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:c=\n" +
"github.com/nats-io/nats.go v1.0.0 h1:d=\ngit.novox.be/novox/mesh-host v0.0.0-1 h1:e=\n"
pkgs := MeshPackagesOf([]byte(sum))
if want := []string{"git.novox.be/novox/mesh-controller/modules/route-proxy/contract", "git.novox.be/novox/mesh-host", "git.novox.be/novox/mesh-sdk/go"}; !reflect.DeepEqual(pkgs, want) {
t.Errorf("the packages of a go.sum: %v, want %v", pkgs, want)
}
if got := OtherPackages(pkgs, "novox/mesh-controller"); len(got) != 2 || got[0] != "git.novox.be/novox/mesh-host" {
t.Errorf("the packages of other repositories: %v", got)
}
held := map[string]Module{
"mesh-sdk": {Repository: "novox/mesh-sdk", Path: RootPath, Commit: "c-sdk"},
"route-proxy": {Repository: "novox/mesh-controller", Path: "modules/route-proxy", Commit: "c-rp"},
"controller": {Repository: "novox/mesh-controller", Path: RootPath, Commit: "c-ctl"},
"route-proxy2": {Repository: "novox/mesh-controller", Path: "modules/route", Commit: "c-wrong"},
"no-path": {Repository: "novox/mesh-host", Commit: "c-host"},
}
versions, missing := PackageVersionsOf(pkgs, held)
if want := (PackageVersions{"git.novox.be/novox/mesh-sdk/go": "c-sdk", "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": "c-rp"}); !reflect.DeepEqual(versions, want) {
t.Errorf("versions %v, want %v", versions, want)
}
if len(missing) != 1 || missing[0] != "git.novox.be/novox/mesh-host" {
t.Errorf("missing %v: a module that names no directory holds no package", missing)
}
kept.Packages = []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-host"}
if fp, none := SourceFingerprint2(kept.WithPackages("m", tc, versions)); fp != "" || !strings.HasPrefix(none, NoPackageVersion) {
t.Errorf("a package with no version: %q %q", fp, none)
}
} }
+107
View File
@@ -0,0 +1,107 @@
package delivery
import (
"maps"
"slices"
"strings"
)
// **Every module builds against the latest published version of each mesh package it uses** (novox/hq ADR 0573 §2;
// issue 1585). A mesh package is a Go module of a repository of the mesh's forge (ForgeModulePrefix): mesh-sdk's Go
// module, and a seat's contract its holder's repository publishes (ADR 0573 §1). Its version is the trunk merge the
// indexer holds for the module whose directory holds the package (PackageVersionsOf): a merge that changes it changes
// the fingerprint of every module that uses it, and the build writes that version into its own go.mod and go.sum
// (StepAsk.Packages). Which packages a module uses is read from its lock file by MeshPackagesOf, the same way by the
// forge bridge and by the build modules (buildstep.ModuleTrees), so nobody names a version by hand.
// ForgeModulePrefix is the module path prefix of the Go modules the mesh's forge serves: a Go module whose path starts
// with it is a mesh package, git.novox.be/novox/<repository>[/<directory of the module in it>].
const ForgeModulePrefix = "git.novox.be/novox/"
// MeshPackagesOf are the mesh packages a Go module's go.sum names (the module path of each line that starts with
// ForgeModulePrefix), sorted and each once. go.sum names every module of the build's graph, so a package used only
// through another is named too. Nil for none. A package of the module's own repository comes with its own tree, and
// its reader leaves it out (OtherPackages).
func MeshPackagesOf(goSum []byte) []string {
seen := map[string]bool{}
for _, line := range strings.Split(string(goSum), "\n") {
f := strings.Fields(line)
if len(f) < 2 || !strings.HasPrefix(f[0], ForgeModulePrefix) {
continue
}
if _, _, ok := PackageRepository(f[0]); !ok {
continue
}
seen[f[0]] = true
}
if len(seen) == 0 {
return nil
}
return slices.Sorted(maps.Keys(seen))
}
// OtherPackages are the packages not published from repository, the module's own, in their order; nil for none.
func OtherPackages(packages []string, repository string) []string {
var out []string
for _, p := range packages {
if repo, _, ok := PackageRepository(p); ok && !SameRepository(repo, repository) {
out = append(out, p)
}
}
return out
}
// PackageRepository is the repository (<owner>/<name>) a mesh package is published from and the package's directory in
// it ("" for its root); false for a path that is not a forge module's.
func PackageRepository(pkg string) (repository, dir string, ok bool) {
rest, found := strings.CutPrefix(pkg, ForgeModulePrefix)
if !found || rest == "" {
return "", "", false
}
name, dir, _ := strings.Cut(rest, "/")
if name == "" {
return "", "", false
}
return "novox/" + name, dir, true
}
// PackageVersionsOf is the latest published version of each package from the modules the indexer holds (by key): the
// trunk merge commit of the module of the package's repository whose directory holds the package's directory (the
// deepest, a module at the repository's root holding every directory). A package no held module holds, or whose
// module names no commit or no directory, is in missing, sorted; versions is nil when packages is empty.
func PackageVersionsOf(packages []string, modules map[string]Module) (versions PackageVersions, missing []string) {
for _, pkg := range packages {
repository, dir, ok := PackageRepository(pkg)
commit, depth := "", -1
if ok {
for _, key := range slices.Sorted(maps.Keys(modules)) {
m := modules[key]
if !SameRepository(m.Repository, repository) || m.Commit == "" {
continue
}
at := strings.Trim(m.Path, "/")
switch {
case at == "":
continue // a value that names no directory says nothing of where the package lies
case at == RootPath:
at = ""
case dir != at && !strings.HasPrefix(dir, at+"/"):
continue
}
if d := len(at); d > depth {
commit, depth = m.Commit, d
}
}
}
if commit == "" {
missing = append(missing, pkg)
continue
}
if versions == nil {
versions = PackageVersions{}
}
versions[pkg] = commit
}
slices.Sort(missing)
return versions, missing
}
+2
View File
@@ -107,6 +107,8 @@ type PreviewModule struct {
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge; Tree // Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge; Tree
// is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise. // is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"` Paths map[string]string `json:"paths,omitempty"`
// Packages are the mesh packages the module's lock file names at the merge (MeshPackagesOf, novox/hq ADR 0573 §2).
Packages []string `json:"packages,omitempty"`
} }
// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge // Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge
+38 -3
View File
@@ -284,6 +284,10 @@ type CandidateModule struct {
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the preview // Paths are, of a module whose every artifact names its paths, each named path's git object id at the preview
// (novox/hq ADR 0499 clause 2), so the builder's fetch request for the candidate names them. Absent otherwise. // (novox/hq ADR 0499 clause 2), so the builder's fetch request for the candidate names them. Absent otherwise.
Paths map[string]string `json:"paths,omitempty"` Paths map[string]string `json:"paths,omitempty"`
// Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2, §4): the latest
// published version of each, or the head of the candidate's pull request that publishes it, so a group's candidate
// builds against the group's own package. Absent for none.
Packages PackageVersions `json:"packages,omitempty"`
} }
func (m CandidateModule) check() error { func (m CandidateModule) check() error {
@@ -296,6 +300,9 @@ func (m CandidateModule) check() error {
if err := checkPathObjects(m.Paths); err != nil { if err := checkPathObjects(m.Paths); err != nil {
return err return err
} }
if err := m.Packages.Check(); err != nil {
return err
}
return checkNewForm(m.FP2, m.Manifest) return checkNewForm(m.FP2, m.Manifest)
} }
@@ -415,6 +422,10 @@ type Module struct {
// so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's // so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's
// decision L8 of R4e). Absent for an artifact that names none, and in a value written before. // decision L8 of R4e). Absent for an artifact that names none, and in a value written before.
ArtifactPaths map[string][]string `json:"paths,omitempty"` ArtifactPaths map[string][]string `json:"paths,omitempty"`
// Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2): the trunk merge the
// indexer held for each package of Inputs2.Packages. A builder builds the fingerprint against exactly these
// (StepAsk.Packages). Empty when FP2 is empty or the module uses no mesh package.
Packages PackageVersions `json:"packages,omitempty"`
} }
// RootPath is Module.Path of a module whose manifest is its repository's root. // RootPath is Module.Path of a module whose manifest is its repository's root.
@@ -433,6 +444,10 @@ type KeptInputs2 struct {
// computation from kept inputs gives the merge's value (novox/hq ADR 0499 clause 2). Absent for every other module, // computation from kept inputs gives the merge's value (novox/hq ADR 0499 clause 2). Absent for every other module,
// whose Tree is its directory's tree, and in a value written before. // whose Tree is its directory's tree, and in a value written before.
Paths map[string]string `json:"paths,omitempty"` Paths map[string]string `json:"paths,omitempty"`
// Packages are the mesh packages the module's lock file names (MeshPackagesOf, novox/hq ADR 0573 §2): the
// fingerprint takes the latest published version of each (Module.Packages). Absent for none, and in a value written
// before.
Packages []string `json:"packages,omitempty"`
} }
// checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object. // checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object.
@@ -469,6 +484,19 @@ func (k KeptInputs2) With(module string, toolchains map[string]Toolchain2) Sourc
Locks: k.Locks, Toolchains: toolchains} Locks: k.Locks, Toolchains: toolchains}
} }
// WithPackages is With and the version of each package the module uses (Packages), as PackageVersionsOf read them: a
// package with no version enters with none, and the fingerprint is then none (SourceFingerprint2).
func (k KeptInputs2) WithPackages(module string, toolchains map[string]Toolchain2, versions PackageVersions) SourceInputs2 {
in := k.With(module, toolchains)
if len(k.Packages) > 0 {
in.Packages = map[string]string{}
for _, p := range k.Packages {
in.Packages[p] = versions[p]
}
}
return in
}
func (m Module) check() error { func (m Module) check() error {
switch { switch {
case m.FP != "" && m.None != "": case m.FP != "" && m.None != "":
@@ -485,6 +513,9 @@ func (m Module) check() error {
return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language) return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language)
} }
} }
if err := m.Packages.Check(); err != nil {
return err
}
if m.Inputs2 != nil && m.Manifest == "" { if m.Inputs2 != nil && m.Manifest == "" {
return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest") return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest")
} }
@@ -534,9 +565,13 @@ type Forge struct {
// id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The // id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The
// indexer names the paths; a message without them gives such a module none. Older writers send none. // indexer names the paths; a message without them gives such a module none. Older writers send none.
Paths map[string]map[string]string `json:"paths,omitempty"` Paths map[string]map[string]string `json:"paths,omitempty"`
Act string `json:"act,omitempty"` // Packages are, of each module directory, the mesh packages its lock file names (MeshPackagesOf; novox/hq ADR 0573
Words string `json:"words,omitempty"` // §2): the indexer keeps them in the module's inputs and takes each one's latest published version into its
Open []OpenPull `json:"open,omitempty"` // fingerprint. Absent for a directory that uses none, and from an older writer.
Packages map[string][]string `json:"packages,omitempty"`
Act string `json:"act,omitempty"`
Words string `json:"words,omitempty"`
Open []OpenPull `json:"open,omitempty"`
// RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is // RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is
// the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is // the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is
// refused, loudly, rather than read with them dropped. // refused, loudly, rather than read with them dropped.
+29
View File
@@ -253,6 +253,30 @@ type StepAsk struct {
Compose *ComposeJob `json:"compose,omitempty"` Compose *ComposeJob `json:"compose,omitempty"`
// Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node. // Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node.
Needs []string `json:"needs,omitempty"` Needs []string `json:"needs,omitempty"`
// Packages are the version of each mesh package the build or the check builds against, by its module path (a Go
// module's path, git.novox.be/novox/mesh-sdk/go; the shape takes a seat's package too), whatever version the
// repository's go.mod names (novox/hq ADR 0573 §2, §4; issue 1585): a Go package's version is a full commit, the
// trunk merge the indexer holds as the package's latest published version or, in a delivery group's check, the
// head of the group's member that publishes it. The build module writes each into its workspace, checksum-verified,
// and names them in its log (PackageVersions). A compose takes none: it builds the mesh-sdk its reader names.
Packages PackageVersions `json:"packages,omitempty"`
}
// PackageVersions are the versions of the mesh packages a build or a check builds against, by package (a Go module's
// path), each a full commit (novox/hq ADR 0573 §2).
type PackageVersions map[string]string
// Check refuses a package that is no module path or a version that is no full commit, naming it.
func (p PackageVersions) Check() error {
for pkg, version := range p {
if pkg == "" || strings.ContainsAny(pkg, " \t\n@") || strings.HasPrefix(pkg, "/") {
return fmt.Errorf("delivery: the package %q is no module path", pkg)
}
if !askCommitOK.MatchString(version) {
return fmt.Errorf("delivery: the package %s names the version %q, which is no full commit", pkg, version)
}
}
return nil
} }
// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer // ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer
@@ -389,6 +413,11 @@ func (a StepAsk) check() error {
return fmt.Errorf("delivery: the ask %s is a check of an image, which has no tests", a.ID) return fmt.Errorf("delivery: the ask %s is a check of an image, which has no tests", a.ID)
case len(a.Needs) > 0 && a.Check == nil: case len(a.Needs) > 0 && a.Check == nil:
return fmt.Errorf("delivery: the ask %s names needs and is no check, whose tests alone need a node", a.ID) return fmt.Errorf("delivery: the ask %s names needs and is no check, whose tests alone need a node", a.ID)
case len(a.Packages) > 0 && a.Compose != nil:
return fmt.Errorf("delivery: the ask %s is a compose and names package versions, which a compose does not take", a.ID)
}
if err := a.Packages.Check(); err != nil {
return fmt.Errorf("the ask %s: %w", a.ID, err)
} }
for _, n := range a.Needs { for _, n := range a.Needs {
if err := CheckNeed(n); err != nil { if err := CheckNeed(n); err != nil {
+12 -3
View File
@@ -17,7 +17,8 @@ func buildAsk() StepAsk {
Build: &BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", FP: "ab12", Build: &BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", FP: "ab12",
Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit, "dir": "modules/mesh-delivery-view", "paths": []any{"cmd"}}, Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit, "dir": "modules/mesh-delivery-view", "paths": []any{"cmd"}},
Artifacts: []AskArtifact{{Artifact: "bundle"}, {Artifact: "base", From: "registry/x@sha256:00"}}, Artifacts: []AskArtifact{{Artifact: "bundle"}, {Artifact: "base", From: "registry/x@sha256:00"}},
Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}}} Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}},
Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit}}
} }
func checkAsk() StepAsk { func checkAsk() StepAsk {
@@ -25,10 +26,11 @@ func checkAsk() StepAsk {
At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC), At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC),
Check: &CheckJob{Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit, "ref": "refs/pull/3/head", "base": askCommit}, Check: &CheckJob{Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit, "ref": "refs/pull/3/head", "base": askCommit},
Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: "", Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: "",
NodeNames: []string{"anchor", "laptop"}}} NodeNames: []string{"anchor", "laptop"}},
Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit, "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": askCommit}}
} }
// 1887/A1a: an ask encodes and decodes with every field. // 1887/A1a: an ask encodes and decodes with every field; 1585/S4: the package versions too.
func TestAskRoundTrips(t *testing.T) { func TestAskRoundTrips(t *testing.T) {
for _, a := range []StepAsk{buildAsk(), checkAsk()} { for _, a := range []StepAsk{buildAsk(), checkAsk()} {
raw, err := EncodeStepAsk(a) raw, err := EncodeStepAsk(a)
@@ -58,6 +60,13 @@ func TestAskRefusesUnknownKindAndLanguage(t *testing.T) {
"commit": func(a *StepAsk) { a.Build.Fetch = map[string]any{"repository": "novox/x", "commit": "main"} }, "commit": func(a *StepAsk) { a.Build.Fetch = map[string]any{"repository": "novox/x", "commit": "main"} },
"path": func(a *StepAsk) { a.Build.Path = "" }, "path": func(a *StepAsk) { a.Build.Path = "" },
"needs": func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, // issue 890: a build needs no node "needs": func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, // issue 890: a build needs no node
// Issue 1585: a package's version is a full commit, of a module path.
"version": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "v0.1.7" },
"package": func(a *StepAsk) { a.Packages["git.novox.be/novox/x y"] = askCommit },
"compose": func(a *StepAsk) {
a.Kind, a.Build = AskCheck, nil
a.Compose = &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit}, Dir: "go", Package: "x", Request: json.RawMessage(`{}`)}
},
} }
for name, change := range cases { for name, change := range cases {
a := buildAsk() a := buildAsk()