diff --git a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go index 8f66a1a9b..d7e75b8f5 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/fetch.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/fetch.go @@ -19,6 +19,7 @@ import ( "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -177,7 +178,9 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id str if taken[i], errs[i] = m.takeVersions(ctx, root, filepath.Join(tree, d), d, req.Packages, &pins); errs[i] != nil { return } - errs[i] = m.downloadModules(ctx, root, filepath.Join(tree, d), d, &pins) + var more []TakenVersion + more, errs[i] = m.downloadModules(ctx, root, filepath.Join(tree, d), d, &pins, req.Packages) + taken[i] = append(taken[i], more...) }() } wg.Wait() @@ -194,21 +197,29 @@ func (m *Module) fetchInto(ctx context.Context, req sdkstep.FetchRequest, id str // downloadModules downloads and verifies the Go modules of the directory dir (d in the tree). A pin of a forge module // that Go cannot fetch is looked for among the forge's pull request heads and handed to Go by a modfile (pins.go); a -// failure that names no such pin is the first failure. -func (m *Module) downloadModules(ctx context.Context, root, dir, d string, pins *sync.Mutex) error { +// failure that names no such pin is the first failure. A requirement at the zero version is taken from the asked +// versions (pins.go takeZeroPins). +func (m *Module) downloadModules(ctx context.Context, root, dir, d string, pins *sync.Mutex, asked delivery.PackageVersions) ([]TakenVersion, error) { + var taken []TakenVersion for { failure := m.downloadAndVerify(ctx, dir, d) if failure == nil { - return nil + return taken, nil + } + // A requirement at the zero version names no commit: it is taken from the asked versions or the trunk's latest. + took, err := m.takeZeroPins(ctx, root, dir, d, failure.Error(), asked, pins) + taken = append(taken, took...) + if err != nil { + return taken, err } pins.Lock() progress, err := m.resolvePins(ctx, root, dir, failure.Error()) pins.Unlock() if err != nil { - return err + return taken, err } - if !progress { - return failure + if !progress && len(took) == 0 { + return taken, failure } } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins.go b/modules/mesh-build-go/cmd/mesh-build-go/pins.go index f51f52398..36105d5f6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins.go @@ -12,16 +12,23 @@ import ( "context" "errors" "fmt" + "maps" "os" "path/filepath" "regexp" + "slices" "strings" + "sync" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" ) const forgePrefix = "git.novox.be/novox/" +// zeroShort is the 12 digits a requirement at the zero version v0.0.0-00010101000000-000000000000 ends in: no commit. +const zeroShort = "000000000000" + var pseudoVersion = regexp.MustCompile(`[-.][0-9]{14}-([0-9a-f]{12})$`) // pin is a requirement of a go.mod on a commit of a repository of the forge, by a pseudo-version. @@ -107,6 +114,9 @@ func (m *Module) resolvePins(ctx context.Context, root, dir, failure string) (bo } progress := false for _, p := range forgePins(string(raw)) { + if p.short == zeroShort { + continue // takeZeroPins': a requirement at the zero version names no commit to look for + } if !strings.Contains(failure, p.short) || strings.Contains(done, "replace "+p.module+" =>") { continue } @@ -241,3 +251,66 @@ func absoluteReplaces(body []byte, dir string) []byte { } return []byte(strings.Join(lines, "\n")) } + +// takeZeroPins takes, for each requirement of dir's go.mod at the zero version that a go failure names, the version it is +// built against (ADR 0573 §2, §4: whatever go.mod names): the commit the ask names for the package; else the head of +// the package's repository among the asked versions (the check's or try's members: another package of the same +// repository names it); else the latest published version, the trunk's head. It never looks for the commit +// 000000000000 among the pull request heads. It answers what it took; none when no zero requirement is named. +func (m *Module) takeZeroPins(ctx context.Context, root, dir, d, failure string, asked delivery.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) { + raw, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil || !strings.Contains(failure, zeroShort) { + return nil, nil + } + resolved := map[string]string{} + if modfile, ok := m.pinModfile(dir); ok { + if b, err := os.ReadFile(modfile); err == nil { + resolved, _ = goModRequirements(string(b)) + for module := range resolved { + if strings.Contains(string(b), "replace "+module+" =>") { + resolved[module] = "replaced" + } + } + } + } + var taken []TakenVersion + for _, p := range forgePins(string(raw)) { + if p.short != zeroShort { + continue + } + if v := resolved[p.module]; v != "" && !strings.HasSuffix(v, zeroShort) { + continue // taken before + } + named := "v0.0.0-00010101000000-" + zeroShort + tv := TakenVersion{Dir: d, Package: p.module, Asked: askedFor(asked, p), Named: named} + why := "the ask names it" + if tv.Asked == "" { + tv.Asked, why = "latest", "the ask names no commit of its repository, so the trunk's latest published version" + } else if _, ok := asked[p.module]; !ok { + why = "the head of its repository among the asked versions" + } + if err := m.takeVersion(ctx, root, dir, &tv, pins); err != nil { + return taken, fmt.Errorf("the requirement of %s at the zero version: %w", p.module, err) + } + tv.Said = strings.TrimPrefix(tv.Said+"; "+why, "; ") + if m.Say != nil { + m.Say("%s: %s names no commit; built at %s (%s)", d, p.module, tv.Built, why) + } + taken = append(taken, tv) + } + return taken, nil +} + +// askedFor is the commit the ask names for the package p: its own, else that of the first package of the same repository +// in order (the members' heads are by repository), else none. +func askedFor(asked delivery.PackageVersions, p pin) string { + if c := asked[p.module]; c != "" { + return c + } + for _, pkg := range slices.Sorted(maps.Keys(asked)) { + if pkg == forgePrefix+p.repo || strings.HasPrefix(pkg, forgePrefix+p.repo+"/") { + return asked[pkg] + } + } + return "" +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go index 48829ce5a..85e28c646 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/pins_test.go @@ -290,3 +290,71 @@ func TestARelativeReplaceStillResolvesInTheTemporaryModfile(t *testing.T) { t.Fatalf("go mod verify in the pins directory: %v\n%s", err, out) } } + +// A go.mod requirement at the zero version v0.0.0-00010101000000-000000000000 names no commit (ADR 0573 §2, §4: whatever +// go.mod names): it is taken from the commit the ask names for the package, else from the head of its repository among +// the asked versions, else from the trunk's latest published version, and never looked for as the commit 000000000000 +// among the pull request heads. +func TestARequirementAtTheZeroVersionIsTakenFromTheAskOrTheTrunk(t *testing.T) { + const sdk = "git.novox.be/novox/mesh-sdk/go" + zeroMod := "module fixture/member\n\ngo 1.26\n\nrequire (\n\t" + sdk + " v0.0.0-00010101000000-" + zeroShort + "\n)\n" + trunk := strings.Repeat("a", 40) + for _, c := range []struct { + name string + packages map[string]any + download string // what go mod download -json answers + want string // the version asked of go + head bool // the asked commit is on no trunk: only a pull request head carries it + }{ + {name: "the ask names the package", packages: map[string]any{sdk: trunk}, want: sdk + "@" + trunk, + download: `{"Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip="}`}, + {name: "the ask names another package of its repository", packages: map[string]any{"git.novox.be/novox/mesh-sdk/other": trunk}, want: sdk + "@" + trunk, + download: `{"Version":"v0.1.15-0.20261101000000-aaaaaaaaaaaa","Sum":"h1:zip="}`}, + {name: "the ask names the head of a pull request", packages: map[string]any{sdk: commitSHA}, want: sdk + "@" + commitSHA, head: true}, + {name: "the ask names nothing of its repository", packages: map[string]any{"git.novox.be/novox/mesh-tools/go": trunk}, want: sdk + "@latest", + download: `{"Version":"v0.1.20-0.20261104000000-bbbbbbbbbbbb","Sum":"h1:zip="}`}, + {name: "no ask", want: sdk + "@latest", download: `{"Version":"v0.1.20-0.20261104000000-bbbbbbbbbbbb","Sum":"h1:zip="}`}, + } { + t.Run(c.name, func(t *testing.T) { + h, states := newPinHarness(t, zeroMod, false, pullList) + base := h.m.Run + h.m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + line := name + " " + strings.Join(args, " ") + switch { + case name == "go" && strings.Contains(line, " mod download -json "): + h.mu.Lock() + h.cmds = append(h.cmds, cmdRecord{env: env, line: line}) + h.mu.Unlock() + if c.head { + return forgekey.Ran{Status: 1, Stdout: `{"Error":"invalid version: unknown revision ` + commitSHA[:12] + `"}`} + } + return forgekey.Ran{Stdout: c.download} + case name == "go" && strings.Contains(line, " mod download") && !strings.Contains(strings.Join(env, " "), "-modfile="): + return forgekey.Ran{Status: 1, Stderr: "go: " + sdk + "@v0.0.0-00010101000000-" + zeroShort + ": invalid version: unknown revision " + zeroShort + "\n"} + } + return base(ctx, env, name, args...) + } + args := map[string]any{"repository": "novox/mesh-delivery", "commit": commitSHA} + if c.packages != nil { + args["packages"] = c.packages + } + if _, err := fetchTool(t, h.m)(args); err != nil { + t.Fatal(err) + } + st := states.waitEnd(t) + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + if !h.ran(" mod download -json " + c.want) { + t.Errorf("go was not asked for %s:\n%s", c.want, strings.Join(h.f.lines(), "\n")) + } + // The pull request heads are listed only to place an asked head, never to look for the commit of zeros. + if listed := h.ran("ls-remote"); listed != c.head { + t.Errorf("the pull request heads were listed: %v, want %v", listed, c.head) + } + if h.ran("pinned/mesh-sdk@" + zeroShort) { + t.Error("a repository was placed at the commit of zeros") + } + }) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/versions.go b/modules/mesh-build-go/cmd/mesh-build-go/versions.go index 726319b66..104a511bb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/versions.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/versions.go @@ -105,6 +105,10 @@ func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVer // Not a commit Go can fetch: the head of a group's pull request (ADR 0573 §4), placed from the forge's heads. // Whether it is one is the forge's fact (placePin), whatever go said. failure := strings.TrimSpace(info.Error + "\n" + r.Err + "\n" + r.Stderr) + if !commitOK.MatchString(tv.Asked) { + // "latest" (the trunk's head) is no commit of a pull request: there is nothing to place. + return fmt.Errorf("the %s version of %s could not be taken (a download failure that may be retaken): %s", tv.Asked, tv.Package, causeLine(failure)) + } repo, sub, _ := strings.Cut(strings.TrimPrefix(tv.Package, forgePrefix), "/") p := pin{module: tv.Package, repo: repo, sub: sub, short: tv.Asked[:12]} pins.Lock()