mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh, merge-check-typescript.sh): main's version judged it; the change's judges the pull r…
mesh/delivery delivered
mesh/delivery-group group fix/385-an-untestable-module-fails-the-check delivered: every member is delivered
A module whose dependencies could not be fetched was said NOT TESTED and the repository check passed, so a merge on it would have landed untested code. The build seat clones the SDK beside every check already: a forge requirement the module cache lacks is now taken from that clone through a go.mod of its own, and a module that still cannot be built fails the check by name and why. A TypeScript module whose dependencies the toolchain lacks fails the same way.
256 lines
8.2 KiB
Go
256 lines
8.2 KiB
Go
// Command deps makes a module's dependencies available to the catalogue's check, or says why it cannot
|
|
// (novox/hq issue 385).
|
|
//
|
|
// A module's tests run only when its dependencies can be fetched. The build seat clones what a check
|
|
// needs with the forge credential and forgets that credential before the check runs, so a module pinning
|
|
// an SDK commit the seat's module cache does not hold cannot fetch it from the forge. The SDK is cloned
|
|
// beside the check all the same (MESH_CHECK_BESIDE, mesh-controller's besideRefs): every requirement of the
|
|
// mesh's own forge is taken from the clone of its repository there, at the commit or the tag its version
|
|
// names, through a go.mod of its own (-modfile) — the module's own files are not changed, and no forge
|
|
// credential is added.
|
|
//
|
|
// Usage: deps -beside <dir> -work <dir> <module dir>. It prints the go.mod to build the module with, or
|
|
// nothing when the module fetches what it needs; when it cannot, it prints why and exits 1, and the check
|
|
// fails: a module whose tests could not run is a failure, never a note.
|
|
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// forge is the prefix of the Go modules of the mesh's own forge: <forge><repository>[/<directory>].
|
|
const forge = "git.novox.be/novox/"
|
|
|
|
func main() {
|
|
beside := flag.String("beside", "", "where the repositories beside the check are cloned (MESH_CHECK_BESIDE)")
|
|
work := flag.String("work", os.TempDir(), "where the go.mod and the sources taken from beside are written")
|
|
flag.Parse()
|
|
if flag.NArg() != 1 {
|
|
fmt.Println("usage: deps -beside <dir> -work <dir> <module dir>")
|
|
os.Exit(2)
|
|
}
|
|
modfile, err := resolve(flag.Arg(0), *beside, *work)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
if modfile != "" {
|
|
fmt.Println(modfile)
|
|
}
|
|
}
|
|
|
|
// resolve is the go.mod the module at dir builds with: "" when its own fetches everything, else one
|
|
// beside it in work that takes every requirement of the mesh's forge from its clone beside the check.
|
|
func resolve(dir, beside, work string) (string, error) {
|
|
out, err := goIn(dir, "mod", "download")
|
|
if err == nil {
|
|
return "", nil
|
|
}
|
|
fetching := lastLine(out)
|
|
required, err := forgeRequirements(dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(required) == 0 {
|
|
return "", fmt.Errorf("its dependencies cannot be fetched: %s", fetching)
|
|
}
|
|
if beside == "" {
|
|
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and nothing was cloned beside the check to "+
|
|
"take %s from", fetching, repositories(required))
|
|
}
|
|
into, err := os.MkdirTemp(work, filepath.Base(dir)+"-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
modfile := filepath.Join(into, "go.mod")
|
|
if err := copyFile(filepath.Join(dir, "go.mod"), modfile); err != nil {
|
|
return "", err
|
|
}
|
|
if err := copyFile(filepath.Join(dir, "go.sum"), filepath.Join(into, "go.sum")); err != nil &&
|
|
!errors.Is(err, os.ErrNotExist) {
|
|
return "", err
|
|
}
|
|
for _, r := range required {
|
|
source, err := export(beside, r, into)
|
|
if err != nil {
|
|
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and %s@%s cannot be taken from the "+
|
|
"clone beside the check: %w", fetching, r.Path, r.Version, err)
|
|
}
|
|
if out, err := goIn(dir, "mod", "edit", "-replace", r.Path+"@"+r.Version+"="+source, modfile); err != nil {
|
|
return "", fmt.Errorf("%s@%s cannot be taken from the clone beside the check: %s", r.Path, r.Version,
|
|
lastLine(out))
|
|
}
|
|
}
|
|
if out, err := goIn(dir, "mod", "download", "-modfile="+modfile); err != nil {
|
|
return "", fmt.Errorf("its dependencies cannot be fetched even with %s taken from the clone beside the "+
|
|
"check: %s", repositories(required), lastLine(out))
|
|
}
|
|
return modfile, nil
|
|
}
|
|
|
|
// requirement is one module the module requires.
|
|
type requirement struct {
|
|
Path string
|
|
Version string
|
|
}
|
|
|
|
// forgeRequirements is every requirement of the module at dir that comes from the mesh's own forge.
|
|
func forgeRequirements(dir string) ([]requirement, error) {
|
|
out, err := goIn(dir, "mod", "edit", "-json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
|
|
}
|
|
var mod struct{ Require []requirement }
|
|
if err := json.Unmarshal(out, &mod); err != nil {
|
|
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
|
|
}
|
|
var on []requirement
|
|
for _, r := range mod.Require {
|
|
if strings.HasPrefix(r.Path, forge) {
|
|
on = append(on, r)
|
|
}
|
|
}
|
|
return on, nil
|
|
}
|
|
|
|
// repositoryOf splits a module path of the forge into its repository and the directory in it.
|
|
func repositoryOf(path string) (string, string) {
|
|
repo, sub, _ := strings.Cut(strings.TrimPrefix(path, forge), "/")
|
|
return repo, sub
|
|
}
|
|
|
|
func repositories(rs []requirement) string {
|
|
var names []string
|
|
for _, r := range rs {
|
|
repo, _ := repositoryOf(r.Path)
|
|
names = append(names, repo)
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// pseudo is a pseudo-version's commit: v0.1.14-0.20261012082113-9747f3e1b1ac → 9747f3e1b1ac.
|
|
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})$`)
|
|
|
|
// refOf is the ref a version names in its repository: a pseudo-version's commit, else the tag a module in
|
|
// a directory is released under (<directory>/<version>).
|
|
func refOf(sub, version string) string {
|
|
version = strings.TrimSuffix(version, "+incompatible")
|
|
if m := pseudo.FindStringSubmatch(version); m != nil {
|
|
return m[1]
|
|
}
|
|
if sub != "" {
|
|
return sub + "/" + version
|
|
}
|
|
return version
|
|
}
|
|
|
|
// export writes the module r names, at its version, from its repository's clone beside the check into
|
|
// into, and answers its directory.
|
|
func export(beside string, r requirement, into string) (string, error) {
|
|
repo, sub := repositoryOf(r.Path)
|
|
clone := filepath.Join(beside, repo)
|
|
if _, err := os.Stat(filepath.Join(clone, ".git")); err != nil {
|
|
return "", fmt.Errorf("no clone of %s is beside the check", repo)
|
|
}
|
|
ref := refOf(sub, r.Version)
|
|
commit, err := exec.Command("git", "-C", clone, "rev-parse", "--verify", "--quiet", ref+"^{commit}").Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("the clone of %s holds no %s (a commit or tag not pushed to the forge?)", repo, ref)
|
|
}
|
|
at := strings.TrimSpace(string(commit))
|
|
args := []string{"-C", clone, "archive", "--format=tar", at}
|
|
if sub != "" {
|
|
args = append(args, "--", sub)
|
|
}
|
|
var stderr bytes.Buffer
|
|
archive := exec.Command("git", args...)
|
|
archive.Stderr = &stderr
|
|
raw, err := archive.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("git archive of %s at %s: %v %s", repo, ref, err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
root := filepath.Join(into, repo+"@"+at[:12])
|
|
if err := untar(raw, root); err != nil {
|
|
return "", err
|
|
}
|
|
source := filepath.Join(root, filepath.FromSlash(sub))
|
|
if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil {
|
|
return "", fmt.Errorf("%s at %s has no go.mod in %q", repo, ref, sub)
|
|
}
|
|
return source, nil
|
|
}
|
|
|
|
// untar writes a tar's directories and regular files under root.
|
|
func untar(raw []byte, root string) error {
|
|
r := tar.NewReader(bytes.NewReader(raw))
|
|
for {
|
|
h, err := r.Next()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := filepath.Clean(filepath.FromSlash(h.Name))
|
|
if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
|
|
continue
|
|
}
|
|
path := filepath.Join(root, name)
|
|
switch h.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(path, body, 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func copyFile(from, to string) error {
|
|
raw, err := os.ReadFile(from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(to, raw, 0o644)
|
|
}
|
|
|
|
// goIn runs the go command in dir.
|
|
func goIn(dir string, args ...string) ([]byte, error) {
|
|
cmd := exec.Command("go", args...)
|
|
cmd.Dir = dir
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return append(out, stderr.Bytes()...), err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// lastLine is the last line a command said, which is where go says what failed.
|
|
func lastLine(out []byte) string {
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
return strings.TrimSpace(lines[len(lines)-1])
|
|
}
|