diff --git a/go/buildstep/askrun.go b/go/buildstep/askrun.go index c9fa172..496019b 100644 --- a/go/buildstep/askrun.go +++ b/go/buildstep/askrun.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "path" "slices" "sort" @@ -42,14 +43,30 @@ type AskSteps struct { // 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) { 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 { - 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) } +// 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) { if r, ok := v.(json.RawMessage); ok { return r, nil diff --git a/go/buildstep/askrun_test.go b/go/buildstep/askrun_test.go index a2dbefe..a21dd88 100644 --- a/go/buildstep/askrun_test.go +++ b/go/buildstep/askrun_test.go @@ -74,15 +74,21 @@ func TestRunAskBuilds(t *testing.T) { steps := r.steps() var released []string 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}, - 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 { 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") 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":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}` 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" { t.Errorf("released %v, want the one workspace", released) } - a := out.(delivery.BuildAnswer) - if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" { - t.Fatalf("answer %+v", a) + answer := out.(delivery.BuildAnswer) + if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" { + t.Fatalf("answer %+v", answer) } } diff --git a/go/buildstep/request.go b/go/buildstep/request.go index c0189dc..345ef00 100644 --- a/go/buildstep/request.go +++ b/go/buildstep/request.go @@ -2,9 +2,12 @@ package buildstep import ( "fmt" + "maps" "path" "regexp" "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) var ( @@ -27,6 +30,10 @@ type FetchRequest struct { Base string `json:"base,omitempty"` Dir string `json:"dir,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. @@ -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"}, "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"}, + "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 { 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 } @@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) { default: 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() } +// 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. func (r FetchRequest) Check() error { if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { diff --git a/go/buildstep/request_test.go b/go/buildstep/request_test.go index 6e13c8f..69102f7 100644 --- a/go/buildstep/request_test.go +++ b/go/buildstep/request_test.go @@ -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 // clause 1). The request survives the trip through a tool call's arguments. 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", - 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() - // 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"} + 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) if err != nil { 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": "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{"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{} for k, v := range ok { diff --git a/go/buildstep/trees.go b/go/buildstep/trees.go index b63065f..49a8f24 100644 --- a/go/buildstep/trees.go +++ b/go/buildstep/trees.go @@ -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 // is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise. 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"} @@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co mt.Locks = map[string]string{} } 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 diff --git a/go/buildstep/trees_test.go b/go/buildstep/trees_test.go index 9cbbf0b..a8b1a90 100644 --- a/go/buildstep/trees_test.go +++ b/go/buildstep/trees_test.go @@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) writeTreeFile(t, root, "module.json", `{"root":1}`) writeTreeFile(t, root, "go.sum", "rootsum") 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/b/module.json", `{"b":1}`) merge := strings.Repeat("c", 40) @@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) t.Fatal(err) } want := map[string]ModuleTree{ - ".": {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")}}, + ".": {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")}, + 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}`)}, } if !reflect.DeepEqual(got, want) { diff --git a/go/delivery/fingerprint2.go b/go/delivery/fingerprint2.go index 23a991a..847d973 100644 --- a/go/delivery/fingerprint2.go +++ b/go/delivery/fingerprint2.go @@ -11,6 +11,10 @@ import ( // SourcePrefix2 opens every source fingerprint of the new form. 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 // 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. @@ -37,15 +41,19 @@ type SourceInputs2 struct { Contexts map[string]string Locks map[string]string 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' // 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 // of maps and a base given twice change nothing. The lines are "module ", "tree " and "builder ", then -// "base " (once each), "context ", "lock " and "toolchain -// ", sorted, all joined by a newline. +// "base " (once each), "context ", "lock ", "toolchain +// " and "package ", 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) { if in.Unpinned != "" { return "", in.Unpinned @@ -58,6 +66,16 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) { 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} bases := map[string]bool{} for _, b := range in.Bases { @@ -75,6 +93,9 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) { for a, t := range in.Toolchains { 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:]) sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) return SourcePrefix2 + hex.EncodeToString(sum[:]), "" diff --git a/go/delivery/fingerprint2_test.go b/go/delivery/fingerprint2_test.go index 053b8dc..f53031a 100644 --- a/go/delivery/fingerprint2_test.go +++ b/go/delivery/fingerprint2_test.go @@ -62,6 +62,9 @@ func TestFingerprint2Changes(t *testing.T) { "a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" }, "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"} }, + // 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() f(&in) @@ -111,6 +114,17 @@ func TestFingerprint2None(t *testing.T) { if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" { 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.Tree, in.Unpinned = "", "a context is a Go program's build source" if _, none := SourceFingerprint2(in); none != in.Unpinned { diff --git a/go/delivery/forge_inputs_test.go b/go/delivery/forge_inputs_test.go index 4b76ee1..e916159 100644 --- a/go/delivery/forge_inputs_test.go +++ b/go/delivery/forge_inputs_test.go @@ -1,6 +1,7 @@ package delivery import ( + "reflect" "strings" "testing" ) @@ -163,4 +164,35 @@ func TestModuleKeepsInputs2(t *testing.T) { if _, err := EncodeModule(m); err == nil { 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) + } } diff --git a/go/delivery/packages.go b/go/delivery/packages.go new file mode 100644 index 0000000..a039f1e --- /dev/null +++ b/go/delivery/packages.go @@ -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/[/]. +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 (/) 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 +} diff --git a/go/delivery/preview.go b/go/delivery/preview.go index 3679146..2fe3297 100644 --- a/go/delivery/preview.go +++ b/go/delivery/preview.go @@ -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 // is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise. 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 diff --git a/go/delivery/source.go b/go/delivery/source.go index e9d28a0..33595a7 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -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 // (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"` + // 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 { @@ -296,6 +300,9 @@ func (m CandidateModule) check() error { if err := checkPathObjects(m.Paths); err != nil { return err } + if err := m.Packages.Check(); err != nil { + return err + } 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 // decision L8 of R4e). Absent for an artifact that names none, and in a value written before. 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"` // Verdict is the indexer's verdict on Manifest, VerdictAccepted or VerdictRefused, and Problems each problem of a // refusal, as the verdicts state holds them (novox/hq ADR 0578 §1): a reader of modules takes it without a read of // that state. Empty in a value written before the indexer judged, which a reader takes as accepted (TakeManifestVerdict). @@ -446,6 +457,10 @@ type KeptInputs2 struct { // 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. 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. @@ -482,6 +497,19 @@ func (k KeptInputs2) With(module string, toolchains map[string]Toolchain2) Sourc 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 { switch { case m.FP != "" && m.None != "": @@ -498,6 +526,9 @@ func (m Module) check() error { 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 == "" { return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest") } @@ -555,9 +586,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 // 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"` - Act string `json:"act,omitempty"` - Words string `json:"words,omitempty"` - Open []OpenPull `json:"open,omitempty"` + // Packages are, of each module directory, the mesh packages its lock file names (MeshPackagesOf; novox/hq ADR 0573 + // §2): the indexer keeps them in the module's inputs and takes each one's latest published version into its + // 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 // 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. diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index fba438c..17ec656 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -253,6 +253,30 @@ type StepAsk struct { 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 []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 @@ -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) 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) + 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 { if err := CheckNeed(n); err != nil { diff --git a/go/delivery/stepasks_test.go b/go/delivery/stepasks_test.go index 99269cf..6f1acd1 100644 --- a/go/delivery/stepasks_test.go +++ b/go/delivery/stepasks_test.go @@ -17,7 +17,8 @@ func buildAsk() StepAsk { 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"}}, 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 { @@ -25,10 +26,11 @@ func checkAsk() StepAsk { 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}, 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) { for _, a := range []StepAsk{buildAsk(), checkAsk()} { 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"} }, "path": func(a *StepAsk) { a.Build.Path = "" }, "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 { a := buildAsk()