Files
mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/versions.go
T
jschoubben 7a5eef32e7
mesh/delivery merged: source.main moved to novox/mesh-catalog@bc4d43b508cf
mesh/delivery-group group fix/1921-zero-pins-own-packages merged: every member is merged
mesh/check being carried to 7a5eef32e772 on bc4d43b508cf | from df8168f4843f, mesh-delivery c0e5bc2834fe
A zero-version requirement of the checked repository's own module is built from its fetched tree, and a failed latest says what failed (issue 1921)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 23:34:46 +01:00

285 lines
10 KiB
Go

package main
// The asked versions of the mesh's packages (novox/hq ADR 0573 §2, §4; issue 1585): every module builds against the latest
// published version of each mesh package it uses, and a delivery group's check against the group's own heads, whatever
// version its go.mod names. The builder and the checker name each version in the ask (delivery.StepAsk.Packages, a full
// commit by package); before Go downloads anything, the fetch writes them into the temporary -modfile of each directory
// whose go.mod requires the package (pins.go), so the fetch's go commands, the build and the tests all take them:
//
// - a commit Go can fetch (a trunk merge) is resolved to its Go version, required in the modfile, and its checksums
// written beside it, which go mod verify then checks the downloaded module against;
// - a commit Go cannot fetch (the head of a group's pull request) is found among the forge's pull request heads and
// placed beside the workspace, and the modfile replaces the package by it, as a pin on a head is (pins.go);
// - a version go.mod names is the oldest the module accepts: one newer than the asked is built, and said loudly.
//
// Each package's outcome is named in the module's log and in the fetch's answer (FetchResult.Packages).
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// TakenVersion is what the fetch built a directory against for one asked package: the asked commit, the version its go.mod
// names, the version built (a Go version, or the asked commit when it was placed from a pull request's head) and its
// checksum, and why the built one is not the asked one when it is not.
type TakenVersion struct {
Dir string `json:"dir"`
Package string `json:"package"`
Asked string `json:"asked"`
Named string `json:"named"`
Built string `json:"built"`
Sum string `json:"sum,omitempty"`
Said string `json:"said,omitempty"`
}
// downloadInfo is what go mod download -json answers of one module.
type downloadInfo struct {
Version string
Sum string
GoModSum string
Error string
}
// takeVersions writes the asked versions into the modfile of dir (d in the tree) and answers what it took, one entry per
// asked package its go.mod requires. A package go.mod replaces is built as go.mod says, and said. A version that can be
// neither fetched nor found among the forge's pull request heads fails the fetch, naming it. pins serialises the
// placements a workspace's directories share.
func (m *Module) takeVersions(ctx context.Context, root, dir, d string, asked delivery.PackageVersions, pins *sync.Mutex) ([]TakenVersion, error) {
raw, err := os.ReadFile(filepath.Join(dir, "go.mod"))
if err != nil {
return nil, nil
}
required, replaced := goModRequirements(string(raw))
var taken []TakenVersion
for _, pkg := range slices.Sorted(maps.Keys(asked)) {
named, ok := required[pkg]
if !ok {
continue // the module does not use it
}
tv := TakenVersion{Dir: d, Package: pkg, Asked: asked[pkg], Named: named}
switch {
case replaced[pkg]:
tv.Built, tv.Said = named, "go.mod replaces it, so it is built as go.mod says"
default:
if err := m.takeVersion(ctx, root, dir, &tv, pins); err != nil {
return taken, err
}
}
if m.Say != nil {
m.Say("%s: %s is built at %s (asked %.12s, go.mod names %s)%s", d, pkg, tv.Built, tv.Asked, tv.Named, saidSuffix(tv.Said))
}
taken = append(taken, tv)
}
return taken, nil
}
func saidSuffix(said string) string {
if said == "" {
return ""
}
return ": " + said
}
// takeVersion takes one asked version into dir's modfile (takeVersions).
func (m *Module) takeVersion(ctx context.Context, root, dir string, tv *TakenVersion, pins *sync.Mutex) error {
modfile, err := m.ensureModfile(dir)
if err != nil {
return err
}
pinsDir := filepath.Dir(modfile)
r := m.Run(ctx, m.goEnv(), "go", "-C", pinsDir, "mod", "download", "-json", tv.Package+"@"+tv.Asked)
var info downloadInfo
_ = json.Unmarshal([]byte(r.Stdout), &info)
if r.Status != 0 || r.Err != "" || info.Version == "" || info.Error != "" {
// 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("go cannot fetch the latest version of %s, and the ask names no commit of its repository to build it at: either no version of it is published, or the forge did not answer: %s", 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()
where, err := m.placePin(ctx, root, p, failure)
if errors.Is(err, errNotAHead) {
pins.Unlock()
// On no head, and go failed for another cause (the forge or the resolver did not answer): it fails with go's own
// words, and not as a kind the builder holds until the module changes (issue 1748).
return fmt.Errorf("downloading the asked version %s of %s failed (a download failure that may be retaken): %s", tv.Asked, tv.Package, causeLine(failure))
}
if err == nil {
err = m.replaceInModfile(dir, tv.Package, where)
}
pins.Unlock()
if err != nil {
return fmt.Errorf("the asked version %s of %s: %w", tv.Asked, tv.Package, err)
}
tv.Built, tv.Said = tv.Asked, "Go cannot fetch it: placed from the pull request's head, which has no checksum"
return nil
}
if compareVersions(tv.Named, info.Version) > 0 {
// A version go.mod names is the oldest the module accepts (ADR 0573 §2): said loudly, and go.mod's is built.
tv.Built = tv.Named
tv.Said = fmt.Sprintf("go.mod names %s, newer than the asked %s, so go.mod's is built (a version a repository names is the oldest it accepts, ADR 0573 §2)", tv.Named, info.Version)
return nil
}
tv.Built, tv.Sum = info.Version, info.Sum
if tv.Named == info.Version {
return nil
}
if r := m.Run(ctx, m.goEnv(), "go", "-C", pinsDir, "mod", "edit", "-require="+tv.Package+"@"+info.Version); r.Status != 0 || r.Err != "" {
return fmt.Errorf("the asked version %s of %s could not be required: %s", info.Version, tv.Package, causeLine(r.Err+"\n"+r.Stderr))
}
// The checksums Go computed of the download, which go mod verify checks the module cache against.
sums := ""
if info.Sum != "" {
sums += tv.Package + " " + info.Version + " " + info.Sum + "\n"
}
if info.GoModSum != "" {
sums += tv.Package + " " + info.Version + "/go.mod " + info.GoModSum + "\n"
}
f, err := os.OpenFile(filepath.Join(pinsDir, "go.sum"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
return err
}
_, err = f.WriteString(sums)
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// goModRequirements are the module paths a go.mod requires, with the version each names, and those it replaces.
func goModRequirements(gomod string) (required map[string]string, replaced map[string]bool) {
required, replaced = map[string]string{}, map[string]bool{}
block := ""
for _, line := range strings.Split(gomod, "\n") {
if i := strings.Index(line, "//"); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
switch {
case len(f) == 0:
continue
case f[0] == ")":
block = ""
continue
case len(f) == 2 && f[1] == "(":
block = f[0]
continue
}
verb := block
if f[0] == "require" || f[0] == "replace" {
verb, f = f[0], f[1:]
}
switch {
case verb == "require" && len(f) >= 2:
required[f[0]] = f[1]
case verb == "replace" && len(f) >= 1:
replaced[f[0]] = true
}
}
return required, replaced
}
// compareVersions orders two Go module versions (semantic versions, pseudo-versions among them, by their pre-release
// identifiers): negative when a is older than b, 0 when equal, positive when newer. A version that is not a semantic
// version is older than any that is.
func compareVersions(a, b string) int {
pa, oka := parseVersion(a)
pb, okb := parseVersion(b)
switch {
case !oka && !okb:
return strings.Compare(a, b)
case !oka:
return -1
case !okb:
return 1
}
for i := 0; i < 3; i++ {
if c := pa.core[i] - pb.core[i]; c != 0 {
if c < 0 {
return -1
}
return 1
}
}
switch {
case len(pa.pre) == 0 && len(pb.pre) == 0:
return 0
case len(pa.pre) == 0:
return 1 // a release is newer than its pre-releases
case len(pb.pre) == 0:
return -1
}
for i := 0; i < len(pa.pre) && i < len(pb.pre); i++ {
if c := comparePre(pa.pre[i], pb.pre[i]); c != 0 {
return c
}
}
return len(pa.pre) - len(pb.pre)
}
type version struct {
core [3]int
pre []string
}
func parseVersion(v string) (version, bool) {
s, ok := strings.CutPrefix(v, "v")
if !ok {
return version{}, false
}
s, _, _ = strings.Cut(s, "+")
core, pre, hasPre := strings.Cut(s, "-")
parts := strings.Split(core, ".")
if len(parts) != 3 {
return version{}, false
}
var out version
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return version{}, false
}
out.core[i] = n
}
if hasPre {
out.pre = strings.Split(pre, ".")
}
return out, true
}
func comparePre(a, b string) int {
na, ea := strconv.Atoi(a)
nb, eb := strconv.Atoi(b)
switch {
case ea == nil && eb == nil:
return na - nb
case ea == nil:
return -1 // numeric identifiers are older than alphanumeric ones
case eb == nil:
return 1
}
return strings.Compare(a, b)
}
// unknownRevision says whether a go failure is go not knowing the revision (cmd/go's "unknown revision" and the
// "invalid version" it wraps it in): it labels a commit the forge holds on no pull request head (placePin), and decides
// nothing; every other failure is the network's or the forge's.
func unknownRevision(failure string) bool {
return strings.Contains(failure, "unknown revision") || strings.Contains(failure, "invalid version")
}