285 lines
10 KiB
Go
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")
|
|
}
|