From c04fa5ff6266e697b204bdf020e9abbab65e3a60 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:23:10 +0100 Subject: [PATCH] DLV-C3r3, C3r4: a group's check builds its members against the group's tagged versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design 51 *Tagged versions* and ADR 0607 §3, §5: once each package's published is heard, a Go member's ask names the group's tagged version of each package another member publishes (Go's form, v0.1.15-feat-x.2), a package new in the group included, and a TypeScript member's Node fetch takes {name, version} from the registry instead of a packed head. The published is matched by seat, head and name, so one head with several Go packages (mesh-contracts) gets each its own version. With no preview of the merge, a member's manifest is read at its head on the forge, so a package new in the group is asked for on the first check too (the lead's ruling 4 on A2c's finding 4). Needs mesh-sdk's PackageVersions.Check to take a Go semantic version (a2d-sdk-tagged-version.patch, for A1). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../cmd/mesh-delivery-checker/judge.go | 11 +- .../cmd/mesh-delivery-checker/tags.go | 4 +- .../internal/check/judge.go | 17 +- .../internal/check/pins.go | 17 +- .../internal/check/pins_test.go | 76 +++++- .../internal/check/tagwanted.go | 238 +++++++++++++++--- 6 files changed, 306 insertions(+), 57 deletions(-) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index 8809f9fc..4b1974e3 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -159,7 +159,7 @@ func newJudge(d judgeDeps) check.Judge { nodes = d.nodes } ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, - Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Predefined: d.predefined, Tags: d.tags, Logf: d.logf} + Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Logf: d.logf} if d.nodes != nil && d.builds != nil && d.repos != nil { ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf} } @@ -290,6 +290,15 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted r.mu.Unlock() } +// ModuleDir is a module's directory in its repository as the indexer's modules hold it (check.Examiner.Dirs); false for a +// module the indexer holds no directory of. +func (r *repoModules) ModuleDir(module string) (string, bool) { + r.mu.Lock() + defer r.mu.Unlock() + d, ok := r.path[module] + return d, ok && d != "" +} + // PackagePaths are the paths, relative to the repository, that the TypeScript package artifacts of a repository's modules // are built from, sorted: the named paths the indexer keeps in each module's value (delivery.Module.ArtifactPaths; only the // indexer reads them from a manifest, the lead's decision L8), under the module's directory. None for a module whose value diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go index 3562d9fc..5492c26c 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/tags.go @@ -52,8 +52,8 @@ func (t tagAsks) Want(ctx context.Context, w check.TagWanted) error { return err } -func (t tagAsks) Await(ctx context.Context, seat, head string) (check.TaggedPublished, bool) { - return t.heard.Await(ctx, seat, head) +func (t tagAsks) Await(ctx context.Context, seat, head string, names []string) (check.TaggedPublished, bool) { + return t.heard.Await(ctx, seat, head, names) } // heardPublished takes a message of the events consumer in when it is a package registry seat's published, and answers diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index c253d199..7df82208 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -113,6 +113,11 @@ type Examiner struct { Tags TagAsks // PublishBound is how long a group's check waits for each package's published; DefaultPublishBound when 0. PublishBound time.Duration + // Dirs names a module's directory in its repository, from the indexer's modules, so a manifest is read at a member's + // head (tagwanted.go); nil, or a module it does not know, tries modules/ and the repository's root. + Dirs interface { + ModuleDir(module string) (string, bool) + } // Packages names, of a repository, the paths (repository-relative) its TypeScript package artifacts are built from, from // the indexer's modules (delivery.Module.ArtifactPaths, lead decision L8); nil serves no group package. Packages interface { @@ -250,6 +255,8 @@ type state struct { // unpreviewed are the members (pullKey) with no preview of a merge: a head that does not merge, or a fetch that did not // answer. The indexer holds no manifest of them, so the assigner's composition would only wait: it is not awaited. unpreviewed []string + // tags are the group's tagged versions its members are built against (ADR 0607 §3), once the check heard them. + tags groupVersions } // Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot @@ -415,8 +422,12 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict { t.agreed = &delivery.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe} } args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk} - if bm == buildrun.NodeBuildModule && groupPackage != nil && p.Repository != SDKRepository { - args["package"] = groupPackage + if bm == buildrun.NodeBuildModule { + if np, ok := st.tags.nodePackageFor(p.Repository); ok { + args["package"] = map[string]any{"name": np.Name, "version": np.Version} // the group's tagged version (ADR 0607 §3) + } else if groupPackage != nil && p.Repository != SDKRepository { + args["package"] = groupPackage + } } if bm == buildrun.GoBuildModule { beside, err := st.j.besideFor(ctx, st.c, p) @@ -449,7 +460,7 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict { st.unpreviewed = append(st.unpreviewed, pullKey(p)) } st.judgedByMember(p, merged) - if p.Repository == SDKRepository && len(st.c.Pulls) > 1 && st.j.changesPackage(p, merged) { + if p.Repository == SDKRepository && len(st.c.Pulls) > 1 && len(st.tags.node) == 0 && st.j.changesPackage(p, merged) { groupPackage = map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number)} } } diff --git a/modules/mesh-delivery-checker/internal/check/pins.go b/modules/mesh-delivery-checker/internal/check/pins.go index c8db7338..dd64c493 100644 --- a/modules/mesh-delivery-checker/internal/check/pins.go +++ b/modules/mesh-delivery-checker/internal/check/pins.go @@ -31,10 +31,12 @@ type Forge interface { File(ctx context.Context, repository, path, ref string) (string, error) } -// packagesOf is the version of each mesh package a Go check of repository builds against (ADR 0573 §2, §4): the latest -// published version of each package the mesh's modules use (Published), and for a package one of the case's pull -// requests publishes, that pull request's head (mesh-sdk's Go module whenever mesh-sdk is a member). A package of -// repository itself comes with the tree the check fetched, and is left out. Nil for none. +// packagesOf is the version of each mesh package a Go check of repository builds against (ADR 0573 §2, §4, as ADR 0607 +// §5 changes §4): the latest published version of each package the mesh's modules use (Published); for a package a +// member of the case publishes, the group's tagged version of it once the check heard it (design 51 *Tagged versions*, +// also a package new in the group, which has no published version yet), else that member's head (mesh-sdk's Go module +// whenever mesh-sdk is a member). A package of repository itself comes with the tree the check fetched, and is left out. +// Nil for none. func (st *state) packagesOf(repository string) delivery.PackageVersions { versions := delivery.PackageVersions{} if st.j.Published != nil { @@ -45,13 +47,18 @@ func (st *state) packagesOf(repository string) delivery.PackageVersions { versions[SDKModulePath] = "" } } + for pkg := range st.tags.goPackages { + versions[pkg] = "" + } for pkg := range versions { repo, _, ok := delivery.PackageRepository(pkg) if !ok || delivery.SameRepository(repo, repository) { delete(versions, pkg) continue } - if m := st.c.member(repo); m != nil && len(m.Head) == 40 { + if tagged := st.tags.goPackages[pkg]; tagged != "" { + versions[pkg] = tagged + } else if m := st.c.member(repo); m != nil && len(m.Head) == 40 { versions[pkg] = m.Head } if versions[pkg] == "" { diff --git a/modules/mesh-delivery-checker/internal/check/pins_test.go b/modules/mesh-delivery-checker/internal/check/pins_test.go index a4de20ef..65415576 100644 --- a/modules/mesh-delivery-checker/internal/check/pins_test.go +++ b/modules/mesh-delivery-checker/internal/check/pins_test.go @@ -2,6 +2,8 @@ package check import ( "context" + "encoding/json" + "reflect" "maps" "slices" "strings" @@ -9,6 +11,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -68,23 +71,48 @@ func TestAGroupMemberIsBuiltAgainstTheSDKMembersHeadWhateverItsGoModNames(t *tes // Design 51 *Tagged versions* (DLV-C3): a group's check says tag-wanted for each package a member declares and builds no // member before its published; a lone pull request asks nothing; no published within the bound fails naming the package. sdk := catalogue.Manifest{Module: "mesh-sdk", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ - {Name: "go", Kind: catalogue.ArtifactPackage, Language: "go"}, {Name: "lib", Kind: catalogue.ArtifactPackage, Language: "typescript"}}}} - digest := strings.Repeat("c", 64) + {Name: "go", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go"}}, {Name: "lib", Kind: catalogue.ArtifactPackage, Language: "typescript"}}}} + contracts := catalogue.Manifest{Module: "mesh-contracts", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "go", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go"}}, + {Name: "go-gopackageregistry", Kind: catalogue.ArtifactPackage, Language: "go", Paths: []string{"go/seats/gopackageregistry"}}}}} + digest, contractsDigest := strings.Repeat("c", 64), strings.Repeat("d", 64) sdkPull := func() delivery.CandidatePull { p := pullOf(repoSDK, 12, head2) p.Modules = []string{"mesh-sdk"} p.Fps = map[string]delivery.CandidateModule{"mesh-sdk": {FP2: "src2:" + strings.Repeat("f", 64), Manifest: digest}} return p } + unpreviewed := func() delivery.CandidatePull { // a package new in the group: no preview carries its manifest yet + p := sdkPull() + p.Fps = map[string]delivery.CandidateModule{"mesh-sdk": {FP2: "src2:" + strings.Repeat("f", 64)}} + return p + } + contractsPull := func() delivery.CandidatePull { + p := pullOf("novox/mesh-catalog", 4, strings.Repeat("3", 40)) + p.Modules = []string{"mesh-contracts"} + p.Fps = map[string]delivery.CandidateModule{"mesh-contracts": {FP2: "src2:" + strings.Repeat("e", 64), Manifest: contractsDigest}} + return p + } + const sdkGo, contractsGo, seatGo = SDKModulePath, "git.novox.be/novox/mesh-catalog/go", "git.novox.be/novox/mesh-catalog/go/seats/gopackageregistry" for _, row := range []struct { name string pulls []delivery.CandidatePull - published []string // the seats that publish the SDK member's head + published []string // the seats that publish the members' heads wants int fails string + // built is the Go packages mesh-delivery's member is built against (ADR 0607 §3, §5), node the package its Node + // fetch takes from the registry; nil checks none. + built map[string]string + node map[string]any }{ {name: "a group", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), sdkPull()}, - published: []string{"go-package-registry", "npm-package-registry"}, wants: 2}, + published: []string{"go-package-registry", "npm-package-registry"}, wants: 2, + built: map[string]string{sdkGo: "v0.1.15-feat-x.1"}, node: map[string]any{"name": "lib", "version": "0.1.15-feat-x.1"}}, + {name: "a package new in the group, read at its head", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), unpreviewed()}, + published: []string{"go-package-registry", "npm-package-registry"}, wants: 2, built: map[string]string{sdkGo: "v0.1.15-feat-x.1"}}, + {name: "one head, two Go packages (as mesh-contracts' head)", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), contractsPull()}, + published: []string{"go-package-registry"}, wants: 2, + built: map[string]string{contractsGo: "v0.1.15-feat-x.1", seatGo: "v0.1.3-feat-x.1"}}, {name: "a lone pull request", pulls: []delivery.CandidatePull{sdkPull()}}, {name: "a package never published", pulls: []delivery.CandidatePull{pullOf(repoA, 7, head1), sdkPull()}, published: []string{"go-package-registry"}, wants: 2, @@ -92,7 +120,10 @@ func TestAGroupMemberIsBuiltAgainstTheSDKMembersHeadWhateverItsGoModNames(t *tes } { b := newBench(t) b.fetch.Modules = []string{"modules/m"} - b.ex.Read = fixedManifests{digest: sdk} + b.ex.Read = fixedManifests{digest: sdk, contractsDigest: contracts} + b.ex.Modules = langs{repoA: {buildrun.GoBuildModule, buildrun.NodeBuildModule}} + raw, _ := json.Marshal(sdk) + b.ex.Forge = headFiles{repoSDK + " module.json " + head2: string(raw)} tags := &recordedTags{heard: &PublishedHeard{}, publish: row.published} b.ex.Tags, b.ex.PublishBound = tags, 50*time.Millisecond b.asks.Answer = func(a delivery.StepAsk) delivery.Step { @@ -106,7 +137,7 @@ func TestAGroupMemberIsBuiltAgainstTheSDKMembersHeadWhateverItsGoModNames(t *tes t.Errorf("%s: tag-wanted said %d times, want %d: %+v", row.name, len(tags.wanted), row.wants, tags.wanted) } for _, w := range tags.wanted { - if w.Repository != repoSDK || w.Branch != "feat/x" || w.Head != head2 || w.Module != "mesh-sdk" || w.Pull != 12 || w.Tag != "" { + if !slices.Contains([]string{repoSDK, "novox/mesh-catalog"}, w.Repository) || w.Branch != "feat/x" || w.Head == head1 || w.Module == "" || w.Pull == 7 || w.Tag != "" { t.Errorf("%s: tag-wanted = %+v", row.name, w) } } @@ -118,9 +149,32 @@ func TestAGroupMemberIsBuiltAgainstTheSDKMembersHeadWhateverItsGoModNames(t *tes case row.fails != "" && len(b.steps.tool("fetch")) != 0: t.Errorf("%s: a member was built though a package was not published", row.name) } + if row.built != nil { + got := packagesOfFetch(t, b, repoA) + for pkg, version := range row.built { + if got[pkg] != version { + t.Errorf("%s: mesh-delivery is built against %s %q, want the group's tagged version %s (all: %v)", row.name, pkg, got[pkg], version, got) + } + } + } + if row.node != nil { + if got := nodeFetchOf(b, repoA).args["package"]; !reflect.DeepEqual(got, row.node) { + t.Errorf("%s: mesh-delivery's Node fetch takes %v, want the group's tagged version %v from the registry", row.name, got, row.node) + } + } } } +// headFiles are the forge's files by " "; any other is not found. +type headFiles map[string]string + +func (h headFiles) File(_ context.Context, repository, path, ref string) (string, error) { + if text, ok := h[repository+" "+path+" "+ref]; ok { + return text, nil + } + return "", ErrNotFound +} + // recordedTags are the checker's tag asks as a test hears them: each tag-wanted kept, and the seats in publish answering it // at once with a tagged version, as the builder and a registry seat would. type recordedTags struct { @@ -135,13 +189,17 @@ func (r *recordedTags) Want(_ context.Context, w TagWanted) error { r.wanted = append(r.wanted, w) r.mu.Unlock() for _, seat := range r.publish { - r.heard.Heard(seat, TaggedPublished{Name: w.Package, Kind: "package", Commit: w.Head, Tag: "feat-x", Version: "0.1.15-feat-x.1"}) + version := "0.1.15-feat-x.1" + if w.Package == "go-gopackageregistry" { + version = "0.1.3-feat-x.1" + } + r.heard.Heard(seat, TaggedPublished{Name: w.Package, Kind: "package", Commit: w.Head, Tag: "feat-x", Version: version}) } return nil } -func (r *recordedTags) Await(ctx context.Context, seat, head string) (TaggedPublished, bool) { - return r.heard.Await(ctx, seat, head) +func (r *recordedTags) Await(ctx context.Context, seat, head string, names []string) (TaggedPublished, bool) { + return r.heard.Await(ctx, seat, head, names) } type runsAt map[string]string diff --git a/modules/mesh-delivery-checker/internal/check/tagwanted.go b/modules/mesh-delivery-checker/internal/check/tagwanted.go index e2ca1b35..8e1f5b20 100644 --- a/modules/mesh-delivery-checker/internal/check/tagwanted.go +++ b/modules/mesh-delivery-checker/internal/check/tagwanted.go @@ -2,7 +2,10 @@ package check import ( "context" + "errors" "fmt" + "maps" + "path" "slices" "strings" "sync" @@ -47,76 +50,165 @@ type TaggedPublished struct { type TagAsks interface { // Want says one tag-wanted on the checker's family asks. Want(ctx context.Context, t TagWanted) error - // Await answers the tagged version a seat published from a head, once heard; false when ctx ends first. - Await(ctx context.Context, seat, head string) (TaggedPublished, bool) + // Await answers the tagged version a seat published from a head under one of names, once heard; false when ctx ends + // first. A head declaring several packages on one seat (mesh-contracts' Go modules) is told apart by the name. + Await(ctx context.Context, seat, head string, names []string) (TaggedPublished, bool) } // PublishedHeard is the registry seats' published of tagged versions as the checker heard them on its durable consumer -// of the shared events stream, by seat and source commit: a waiter wakes on the event, never on a timer. +// of the shared events stream, by seat and source commit, each name kept: a waiter wakes on the event, never on a timer. type PublishedHeard struct { mu sync.Mutex - heard map[string]TaggedPublished - waiters map[string][]chan TaggedPublished + heard map[string][]TaggedPublished + waiters map[string][]publishedWaiter +} + +type publishedWaiter struct { + names []string + c chan TaggedPublished } func publishedKey(seat, head string) string { return seat + " " + head } +func (h *PublishedHeard) init() { + if h.heard == nil { + h.heard, h.waiters = map[string][]TaggedPublished{}, map[string][]publishedWaiter{} + } +} + // Heard takes one published of a seat in; one of no tagged version (no tag, or no commit) is left out and answers false. +// A later published of the same name and head replaces the earlier. func (h *PublishedHeard) Heard(seat string, p TaggedPublished) bool { if p.Tag == "" || p.Commit == "" { return false } h.mu.Lock() defer h.mu.Unlock() - if h.heard == nil { - h.heard, h.waiters = map[string]TaggedPublished{}, map[string][]chan TaggedPublished{} - } + h.init() k := publishedKey(seat, p.Commit) - h.heard[k] = p - for _, w := range h.waiters[k] { - w <- p - } - delete(h.waiters, k) + h.heard[k] = append(slices.DeleteFunc(h.heard[k], func(o TaggedPublished) bool { return o.Name == p.Name }), p) + h.waiters[k] = slices.DeleteFunc(h.waiters[k], func(w publishedWaiter) bool { + if !slices.Contains(w.names, p.Name) { + return false + } + w.c <- p + return true + }) return true } // Await is TagAsks.Await over what was heard. -func (h *PublishedHeard) Await(ctx context.Context, seat, head string) (TaggedPublished, bool) { +func (h *PublishedHeard) Await(ctx context.Context, seat, head string, names []string) (TaggedPublished, bool) { k := publishedKey(seat, head) h.mu.Lock() - if h.heard == nil { - h.heard, h.waiters = map[string]TaggedPublished{}, map[string][]chan TaggedPublished{} + h.init() + for _, p := range h.heard[k] { + if slices.Contains(names, p.Name) { + h.mu.Unlock() + return p, true + } } - if p, ok := h.heard[k]; ok { - h.mu.Unlock() - return p, true - } - w := make(chan TaggedPublished, 1) + w := publishedWaiter{names: names, c: make(chan TaggedPublished, 1)} h.waiters[k] = append(h.waiters[k], w) h.mu.Unlock() select { - case p := <-w: + case p := <-w.c: return p, true case <-ctx.Done(): h.mu.Lock() - h.waiters[k] = slices.DeleteFunc(h.waiters[k], func(c chan TaggedPublished) bool { return c == w }) + h.waiters[k] = slices.DeleteFunc(h.waiters[k], func(o publishedWaiter) bool { return o.c == w.c }) h.mu.Unlock() + select { + case p := <-w.c: // heard as the wait ended + return p, true + default: + } return TaggedPublished{}, false } } -// declaredPackage is one package a group member declares: the module, the artifact's name and the seat it is published -// through. +// declaredPackage is one package a group member declares: the module, its directory in the repository (RootPath for its +// root, empty when not known), the artifact's name and paths, and the seat it is published through. type declaredPackage struct { pull Pull - module, name string + module, dir string + name string + paths []string language, seat string } +// publishedNames are the names a registry seat's published may carry for a declared package: the builder's +// `/` (its versions' name), the artifact's name, and a Go package's module path. +func (d declaredPackage) publishedNames() []string { + names := []string{d.module + "/" + d.name, d.name} + if d.language == "go" { + names = append(names, d.goModulePath("")) + } + return names +} + +// goModulePath is the Go module path of a declared Go package (design 51 *Go packages*): the forge's module prefix, the +// repository's name, the module's directory and the artifact's first named path; a published name that is already a +// module path of the forge's is taken as it is. +func (d declaredPackage) goModulePath(published string) string { + if strings.HasPrefix(published, delivery.ForgeModulePrefix) { + return published + } + _, name, _ := strings.Cut(d.pull.Repository, "/") + parts := []string{strings.TrimSuffix(delivery.ForgeModulePrefix, "/") + "/" + name} + if dir := strings.Trim(d.dir, "/"); dir != "" && dir != delivery.RootPath { + parts = append(parts, dir) + } + if len(d.paths) > 0 { + if p := strings.Trim(strings.TrimPrefix(d.paths[0], "./"), "/"); p != "" && p != "." { + parts = append(parts, p) + } + } + return path.Join(parts...) +} + +// manifestAtHead is a touched module's manifest read from the forge at its member's head (the lead's ruling 4 on A2c's +// finding 4): with no preview of the merge, a package new in the group is declared only there, never at the trunk. The +// module's directory is the indexer's when it knows the module, else modules/ or the repository's root, the one +// whose manifest names the module. Found is false when no such file names it. +func (st *state) manifestAtHead(ctx context.Context, p Pull, module string) (m catalogue.Manifest, dir string, found bool, err error) { + var dirs []string + if st.j.Dirs != nil { + if d, ok := st.j.Dirs.ModuleDir(module); ok { + dirs = []string{d} + } + } + if dirs == nil { + dirs = []string{"modules/" + module, delivery.RootPath} + } + for _, d := range dirs { + file := "module.json" + if t := strings.Trim(d, "/"); t != "" && t != delivery.RootPath { + file = t + "/module.json" + } + text, err := st.j.Forge.File(ctx, p.Repository, file, p.Head) + if errors.Is(err, ErrNotFound) { + continue + } + if err != nil { + return catalogue.Manifest{}, "", false, fmt.Errorf("the manifest of %s at the head of %s cannot be read%s: %w", module, pullKey(p), unheardWords(err), err) + } + m, err := catalogue.Decode([]byte(text)) + if err != nil { + return catalogue.Manifest{}, "", false, fmt.Errorf("the manifest %s of %s at the head of %s is no manifest: %w", file, module, pullKey(p), err) + } + if m.Module == module { + return m, d, true, nil + } + } + return catalogue.Manifest{}, "", false, nil +} + // declaredPackages are the packages the case's members declare, read from each touched module's manifest at the merge -// preview (the candidate's), else at the trunk (the indexer's manifests): a member that declares none is not asked for. +// preview (the candidate's); with no preview, at the member's head on the forge, so a package new in the group is asked +// for on the first check too (trunk's manifest when no forge is read): a member that declares none is not asked for. func (st *state) declaredPackages(ctx context.Context) ([]declaredPackage, error) { - if st.j.Read == nil { + if st.j.Read == nil && st.j.Forge == nil { return nil, nil } var trunk map[string]string @@ -126,16 +218,34 @@ func (st *state) declaredPackages(ctx context.Context) ([]declaredPackage, error var out []declaredPackage for _, p := range st.c.Pulls { for _, module := range p.Modules { + var m catalogue.Manifest + var dir string digest := p.Fps[module].Manifest - if digest == "" { - digest = trunk[module] - } - if digest == "" { + switch { + case digest != "" && st.j.Read != nil: + var err error + if m, err = st.j.Read.Manifest(ctx, digest); err != nil { + return nil, fmt.Errorf("the manifest of %s (%s) cannot be read, so the packages it declares are not known: %w", module, pullKey(p), err) + } + case st.j.Forge != nil: + var found bool + var err error + if m, dir, found, err = st.manifestAtHead(ctx, p, module); err != nil { + return nil, fmt.Errorf("%v, so the packages it declares are not known", err) + } + if !found { + continue + } + case trunk[module] != "" && st.j.Read != nil: + var err error + if m, err = st.j.Read.Manifest(ctx, trunk[module]); err != nil { + return nil, fmt.Errorf("the manifest of %s (%s) cannot be read, so the packages it declares are not known: %w", module, pullKey(p), err) + } + default: continue } - m, err := st.j.Read.Manifest(ctx, digest) - if err != nil { - return nil, fmt.Errorf("the manifest of %s (%s) cannot be read, so the packages it declares are not known: %w", module, pullKey(p), err) + if dir == "" && st.j.Dirs != nil { + dir, _ = st.j.Dirs.ModuleDir(module) } if m.Build == nil { continue @@ -148,13 +258,66 @@ func (st *state) declaredPackages(ctx context.Context) ([]declaredPackage, error if !ok { return nil, fmt.Errorf("the package %s of %s (%s) is in %q, which no registry seat publishes", a.Name, module, pullKey(p), a.Language) } - out = append(out, declaredPackage{pull: p, module: module, name: a.Name, language: a.Language, seat: seat}) + out = append(out, declaredPackage{pull: p, module: module, dir: dir, name: a.Name, paths: a.Paths, language: a.Language, seat: seat}) } } } return out, nil } +// groupVersions are a group's tagged versions as its check builds its members against them (ADR 0607 §3, §5): the Go +// packages by module path in Go's form (StepAsk.Packages), and the TypeScript packages a member's Node fetch takes from the +// registry, by the repository that publishes them. +type groupVersions struct { + goPackages delivery.PackageVersions + node map[string]NodePackage +} + +// NodePackage is a TypeScript package a group's check hands the Node build module's fetch (its `package`): the name and the +// tagged version it takes from the registry, its integrity checked against the member's lock (ADR 0607 §3). +type NodePackage struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// nodePackageFor is the TypeScript package a member of repository takes as the group's tagged version: another member's +// (the Node build module places mesh-sdk's alone, so mesh-sdk's first); false when the group published none. +func (g *groupVersions) nodePackageFor(repository string) (NodePackage, bool) { + if np, ok := g.node[SDKRepository]; ok && repository != SDKRepository { + return np, true + } + for _, repo := range slices.Sorted(maps.Keys(g.node)) { + if repo != repository { + return g.node[repo], true + } + } + return NodePackage{}, false +} + +// goVersion is a published version in Go's form: a leading v. +func goVersion(v string) string { + if strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} + +// took keeps a declared package's tagged version as the group's. +func (g *groupVersions) took(d declaredPackage, p TaggedPublished) { + switch d.language { + case "go": + if g.goPackages == nil { + g.goPackages = delivery.PackageVersions{} + } + g.goPackages[d.goModulePath(p.Name)] = goVersion(p.Version) + case "typescript": + if g.node == nil { + g.node = map[string]NodePackage{} + } + g.node[d.pull.Repository] = NodePackage{Name: p.Name, Version: p.Version} + } +} + // tagged asks a group's tagged versions before any member is built (design 51 *Tagged versions*): one tag-wanted per // package a member declares, then each package's published awaited within the publish bound. A lone pull request and a // try ask nothing. A package not published within the bound fails the check naming it. @@ -189,11 +352,12 @@ func (st *state) tagged(ctx context.Context) *delivery.Verdict { defer cancel() var late []string for _, d := range decl { - p, ok := st.j.Tags.Await(at, d.seat, d.pull.Head) + p, ok := st.j.Tags.Await(at, d.seat, d.pull.Head, d.publishedNames()) if !ok { late = append(late, fmt.Sprintf("%s of %s (%s) on %s", d.name, d.module, pullKey(d.pull), d.seat)) continue } + st.tags.took(d, p) st.c.note(fmt.Sprintf("%s of %s is built as %s %s (tag %s)", d.name, d.module, p.Name, p.Version, p.Tag)) } if len(late) > 0 {