diff options
Diffstat (limited to '')
-rw-r--r-- | dependencies/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/cmd/gorelease/gorelease.go | 1517 |
1 files changed, 1517 insertions, 0 deletions
diff --git a/dependencies/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/cmd/gorelease/gorelease.go b/dependencies/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/cmd/gorelease/gorelease.go new file mode 100644 index 0000000..b0ee1e9 --- /dev/null +++ b/dependencies/pkg/mod/golang.org/x/exp@v0.0.0-20220613132600-b0d781184e0d/cmd/gorelease/gorelease.go @@ -0,0 +1,1517 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// gorelease is an experimental tool that helps module authors avoid common +// problems before releasing a new version of a module. +// +// Usage: +// +// gorelease [-base={version|none}] [-version=version] +// +// Examples: +// +// # Compare with the latest version and suggest a new version. +// gorelease +// +// # Compare with a specific version and suggest a new version. +// gorelease -base=v1.2.3 +// +// # Compare with the latest version and check a specific new version for compatibility. +// gorelease -version=v1.3.0 +// +// # Compare with a specific version and check a specific new version for compatibility. +// gorelease -base=v1.2.3 -version=v1.3.0 +// +// gorelease analyzes changes in the public API and dependencies of the main +// module. It compares a base version (set with -base) with the currently +// checked out revision. Given a proposed version to release (set with +// -version), gorelease reports whether the changes are consistent with +// semantic versioning. If no version is proposed with -version, gorelease +// suggests the lowest version consistent with semantic versioning. +// +// If there are no visible changes in the module's public API, gorelease +// accepts versions that increment the minor or patch version numbers. For +// example, if the base version is "v2.3.1", gorelease would accept "v2.3.2" or +// "v2.4.0" or any prerelease of those versions, like "v2.4.0-beta". If no +// version is proposed, gorelease would suggest "v2.3.2". +// +// If there are only backward compatible differences in the module's public +// API, gorelease only accepts versions that increment the minor version. For +// example, if the base version is "v2.3.1", gorelease would accept "v2.4.0" +// but not "v2.3.2". +// +// If there are incompatible API differences for a proposed version with +// major version 1 or higher, gorelease will exit with a non-zero status. +// Incompatible differences may only be released in a new major version, which +// requires creating a module with a different path. For example, if +// incompatible changes are made in the module "example.com/mod", a +// new major version must be released as a new module, "example.com/mod/v2". +// For a proposed version with major version 0, which allows incompatible +// changes, gorelease will describe all changes, but incompatible changes +// will not affect its exit status. +// +// For more information on semantic versioning, see https://semver.org. +// +// Note: gorelease does not accept build metadata in releases (like +// v1.0.0+debug). Although it is valid semver, the Go tool and other tools in +// the ecosystem do not support it, so its use is not recommended. +// +// gorelease accepts the following flags: +// +// -base=version: The version that the current version of the module will be +// compared against. This may be a version like "v1.5.2", a version query like +// "latest", or "none". If the version is "none", gorelease will not compare the +// current version against any previous version; it will only validate the +// current version. This is useful for checking the first release of a new major +// version. The version may be preceded by a different module path and an '@', +// like -base=example.com/mod/v2@v2.5.2. This is useful to compare against +// an earlier major version or a fork. If -base is not specified, gorelease will +// attempt to infer a base version from the -version flag and available released +// versions. +// +// -version=version: The proposed version to be released. If specified, +// gorelease will confirm whether this version is consistent with changes made +// to the module's public API. gorelease will exit with a non-zero status if the +// version is not valid. +// +// gorelease is eventually intended to be merged into the go command +// as "go release". See golang.org/issues/26420. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "go/build" + "io" + "io/ioutil" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "unicode" + + "golang.org/x/exp/apidiff" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" + "golang.org/x/mod/zip" + "golang.org/x/tools/go/packages" +) + +// IDEAS: +// * Should we suggest versions at all or should -version be mandatory? +// * Verify downstream modules have licenses. May need an API or library +// for this. Be clear that we can't provide legal advice. +// * Internal packages may be relevant to submodules (for example, +// golang.org/x/tools/internal/lsp is imported by golang.org/x/tools). +// gorelease should detect whether this is the case and include internal +// directories in comparison. It should be possible to opt out or specify +// a different list of submodules. +// * Decide what to do about build constraints, particularly GOOS and GOARCH. +// The API may be different on some platforms (e.g., x/sys). +// Should gorelease load packages in multiple configurations in the same run? +// Is it a compatible change if the same API is available for more platforms? +// Is it an incompatible change for fewer? +// How about cgo? Is adding a new cgo dependency an incompatible change? +// * Support splits and joins of nested modules. For example, if we are +// proposing to tag a particular commit as both cloud.google.com/go v0.46.2 +// and cloud.google.com/go/storage v1.0.0, we should ensure that the sets of +// packages provided by those modules are disjoint, and we should not report +// the packages moved from one to the other as an incompatible change (since +// the APIs are still compatible, just with a different module split). + +// TODO(jayconrod): +// * Clean up overuse of fmt.Errorf. +// * Support migration to modules after v2.x.y+incompatible. Requires comparing +// packages with different module paths. +// * Error when packages import from earlier major version of same module. +// (this may be intentional; look for real examples first). +// * Mechanism to suppress error messages. + +func main() { + log.SetFlags(0) + log.SetPrefix("gorelease: ") + wd, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + ctx := context.WithValue(context.Background(), "env", append(os.Environ(), "GO111MODULE=on")) + success, err := runRelease(ctx, os.Stdout, wd, os.Args[1:]) + if err != nil { + if _, ok := err.(*usageError); ok { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } else { + log.Fatal(err) + } + } + if !success { + os.Exit(1) + } +} + +// runRelease is the main function of gorelease. It's called by tests, so +// it writes to w instead of os.Stdout and returns an error instead of +// exiting. +func runRelease(ctx context.Context, w io.Writer, dir string, args []string) (success bool, err error) { + // Validate arguments and flags. We'll print our own errors, since we want to + // test without printing to stderr. + fs := flag.NewFlagSet("gorelease", flag.ContinueOnError) + fs.Usage = func() {} + fs.SetOutput(ioutil.Discard) + var baseOpt, releaseVersion string + fs.StringVar(&baseOpt, "base", "", "previous version to compare against") + fs.StringVar(&releaseVersion, "version", "", "proposed version to be released") + if err := fs.Parse(args); err != nil { + return false, &usageError{err: err} + } + + if len(fs.Args()) > 0 { + return false, usageErrorf("no arguments allowed") + } + + if releaseVersion != "" { + if semver.Build(releaseVersion) != "" { + return false, usageErrorf("release version %q is not a canonical semantic version: build metadata is not supported", releaseVersion) + } + if c := semver.Canonical(releaseVersion); c != releaseVersion { + return false, usageErrorf("release version %q is not a canonical semantic version", releaseVersion) + } + } + + var baseModPath, baseVersion string + if at := strings.Index(baseOpt, "@"); at >= 0 { + baseModPath = baseOpt[:at] + baseVersion = baseOpt[at+1:] + } else if dot, slash := strings.Index(baseOpt, "."), strings.Index(baseOpt, "/"); dot >= 0 && slash >= 0 && dot < slash { + baseModPath = baseOpt + } else { + baseVersion = baseOpt + } + if baseModPath == "" { + if baseVersion != "" && semver.Canonical(baseVersion) == baseVersion && releaseVersion != "" { + if cmp := semver.Compare(baseOpt, releaseVersion); cmp == 0 { + return false, usageErrorf("-base and -version must be different") + } else if cmp > 0 { + return false, usageErrorf("base version (%q) must be lower than release version (%q)", baseVersion, releaseVersion) + } + } + } else if baseModPath != "" && baseVersion == "none" { + return false, usageErrorf(`base version (%q) cannot have version "none" with explicit module path`, baseOpt) + } + + // Find the local module and repository root directories. + modRoot, err := findModuleRoot(dir) + if err != nil { + return false, err + } + repoRoot := findRepoRoot(modRoot) + + // Load packages for the version to be released from the local directory. + release, err := loadLocalModule(ctx, modRoot, repoRoot, releaseVersion) + if err != nil { + return false, err + } + + // Find the base version if there is one, download it, and load packages from + // the module cache. + var max string + if baseModPath == "" { + if baseVersion != "" && semver.Canonical(baseVersion) == baseVersion && module.Check(release.modPath, baseVersion) != nil { + // Base version was specified, but it's not consistent with the release + // module path, for example, the module path is example.com/m/v2, but + // the user said -base=v1.0.0. Instead of making the user explicitly + // specify the base module path, we'll adjust the major version suffix. + prefix, _, _ := module.SplitPathVersion(release.modPath) + major := semver.Major(baseVersion) + if strings.HasPrefix(prefix, "gopkg.in/") { + baseModPath = prefix + "." + semver.Major(baseVersion) + } else if major >= "v2" { + baseModPath = prefix + "/" + major + } else { + baseModPath = prefix + } + } else { + baseModPath = release.modPath + max = releaseVersion + } + } + base, err := loadDownloadedModule(ctx, baseModPath, baseVersion, max) + if err != nil { + return false, err + } + + // Compare packages and check for other issues. + report, err := makeReleaseReport(ctx, base, release) + if err != nil { + return false, err + } + if _, err := fmt.Fprint(w, report.String()); err != nil { + return false, err + } + return report.isSuccessful(), nil +} + +type moduleInfo struct { + modRoot string // module root directory + repoRoot string // repository root directory (may be "") + modPath string // module path in go.mod + version string // resolved version or "none" + versionQuery string // a query like "latest" or "dev-branch", if specified + versionInferred bool // true if the version was unspecified and inferred + highestTransitiveVersion string // version of the highest transitive self-dependency (cycle) + modPathMajor string // major version suffix like "/v3" or ".v2" + tagPrefix string // prefix for version tags if module not in repo root + + goModPath string // file path to go.mod + goModData []byte // content of go.mod + goSumData []byte // content of go.sum + goModFile *modfile.File // parsed go.mod file + + diagnostics []string // problems not related to loading specific packages + pkgs []*packages.Package // loaded packages with type information + + // Versions of this module which already exist. Only loaded for release + // (not base). + existingVersions []string +} + +// loadLocalModule loads information about a module and its packages from a +// local directory. +// +// modRoot is the directory containing the module's go.mod file. +// +// repoRoot is the root directory of the repository containing the module or "". +// +// version is a proposed version for the module or "". +func loadLocalModule(ctx context.Context, modRoot, repoRoot, version string) (m moduleInfo, err error) { + if repoRoot != "" && !hasFilePathPrefix(modRoot, repoRoot) { + return moduleInfo{}, fmt.Errorf("module root %q is not in repository root %q", modRoot, repoRoot) + } + + // Load the go.mod file and check the module path and go version. + m = moduleInfo{ + modRoot: modRoot, + repoRoot: repoRoot, + version: version, + goModPath: filepath.Join(modRoot, "go.mod"), + } + + if version != "" && semver.Compare(version, "v0.0.0-99999999999999-zzzzzzzzzzzz") < 0 { + m.diagnostics = append(m.diagnostics, fmt.Sprintf("Version %s is lower than most pseudo-versions. Consider releasing v0.1.0-0 instead.", version)) + } + + m.goModData, err = ioutil.ReadFile(m.goModPath) + if err != nil { + return moduleInfo{}, err + } + m.goModFile, err = modfile.ParseLax(m.goModPath, m.goModData, nil) + if err != nil { + return moduleInfo{}, err + } + if m.goModFile.Module == nil { + return moduleInfo{}, fmt.Errorf("%s: module directive is missing", m.goModPath) + } + m.modPath = m.goModFile.Module.Mod.Path + if err := checkModPath(m.modPath); err != nil { + return moduleInfo{}, err + } + var ok bool + _, m.modPathMajor, ok = module.SplitPathVersion(m.modPath) + if !ok { + // we just validated the path above. + panic(fmt.Sprintf("could not find version suffix in module path %q", m.modPath)) + } + if m.goModFile.Go == nil { + m.diagnostics = append(m.diagnostics, "go.mod: go directive is missing") + } + + // Determine the version tag prefix for the module within the repository. + if repoRoot != "" && modRoot != repoRoot { + if strings.HasPrefix(m.modPathMajor, ".") { + m.diagnostics = append(m.diagnostics, fmt.Sprintf("%s: module path starts with gopkg.in and must be declared in the root directory of the repository", m.modPath)) + } else { + codeDir := filepath.ToSlash(modRoot[len(repoRoot)+1:]) + var altGoModPath string + if m.modPathMajor == "" { + // module has no major version suffix. + // codeDir must be a suffix of modPath. + // tagPrefix is codeDir with a trailing slash. + if strings.HasSuffix(m.modPath, "/"+codeDir) { + m.tagPrefix = codeDir + "/" + } else { + m.diagnostics = append(m.diagnostics, fmt.Sprintf("%s: module path must end with %[2]q, since it is in subdirectory %[2]q", m.modPath, codeDir)) + } + } else { + if strings.HasSuffix(m.modPath, "/"+codeDir) { + // module has a major version suffix and is in a major version subdirectory. + // codeDir must be a suffix of modPath. + // tagPrefix must not include the major version. + m.tagPrefix = codeDir[:len(codeDir)-len(m.modPathMajor)+1] + altGoModPath = modRoot[:len(modRoot)-len(m.modPathMajor)+1] + "go.mod" + } else if strings.HasSuffix(m.modPath, "/"+codeDir+m.modPathMajor) { + // module has a major version suffix and is not in a major version subdirectory. + // codeDir + modPathMajor is a suffix of modPath. + // tagPrefix is codeDir with a trailing slash. + m.tagPrefix = codeDir + "/" + altGoModPath = filepath.Join(modRoot, m.modPathMajor[1:], "go.mod") + } else { + m.diagnostics = append(m.diagnostics, fmt.Sprintf("%s: module path must end with %[2]q or %q, since it is in subdirectory %[2]q", m.modPath, codeDir, codeDir+m.modPathMajor)) + } + } + + // Modules with major version suffixes can be defined in two places + // (e.g., sub/go.mod and sub/v2/go.mod). They must not be defined in both. + if altGoModPath != "" { + if data, err := ioutil.ReadFile(altGoModPath); err == nil { + if altModPath := modfile.ModulePath(data); m.modPath == altModPath { + goModRel, _ := filepath.Rel(repoRoot, m.goModPath) + altGoModRel, _ := filepath.Rel(repoRoot, altGoModPath) + m.diagnostics = append(m.diagnostics, fmt.Sprintf("module is defined in two locations:\n\t%s\n\t%s", goModRel, altGoModRel)) + } + } + } + } + } + + // Load the module's packages. + // We pack the module into a zip file and extract it to a temporary directory + // as if it were published and downloaded. We'll detect any errors that would + // occur (for example, invalid file names). We avoid loading it as the + // main module. + tmpModRoot, err := copyModuleToTempDir(repoRoot, m.modPath, m.modRoot) + if err != nil { + return moduleInfo{}, err + } + defer func() { + if rerr := os.RemoveAll(tmpModRoot); err == nil && rerr != nil { + err = fmt.Errorf("removing temporary module directory: %v", rerr) + } + }() + tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths, prepareDiagnostics, err := prepareLoadDir(ctx, m.goModFile, m.modPath, tmpModRoot, version, false) + if err != nil { + return moduleInfo{}, err + } + defer func() { + if rerr := os.RemoveAll(tmpLoadDir); err == nil && rerr != nil { + err = fmt.Errorf("removing temporary load directory: %v", rerr) + } + }() + + var loadDiagnostics []string + m.pkgs, loadDiagnostics, err = loadPackages(ctx, m.modPath, tmpModRoot, tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths) + if err != nil { + return moduleInfo{}, err + } + + m.diagnostics = append(m.diagnostics, prepareDiagnostics...) + m.diagnostics = append(m.diagnostics, loadDiagnostics...) + + highestVersion, err := findSelectedVersion(ctx, tmpLoadDir, m.modPath) + if err != nil { + return moduleInfo{}, err + } + + if highestVersion != "" { + // A version of the module is included in the transitive dependencies. + // Add it to the moduleInfo so that the release report stage can use it + // in verifying the version or suggestion a new version, depending on + // whether the user provided a version already. + m.highestTransitiveVersion = highestVersion + } + + retracted, err := loadRetractions(ctx, tmpLoadDir) + if err != nil { + return moduleInfo{}, err + } + m.diagnostics = append(m.diagnostics, retracted...) + + return m, nil +} + +// loadDownloadedModule downloads a module and loads information about it and +// its packages from the module cache. +// +// modPath is the module path used to fetch the module. The module's path in +// go.mod (m.modPath) may be different, for example in a soft fork intended as +// a replacement. +// +// version is the version to load. It may be "none" (indicating nothing should +// be loaded), "" (the highest available version below max should be used), a +// version query (to be resolved with 'go list'), or a canonical version. +// +// If version is "" and max is not "", available versions greater than or equal +// to max will not be considered. Typically, loadDownloadedModule is used to +// load the base version, and max is the release version. +func loadDownloadedModule(ctx context.Context, modPath, version, max string) (m moduleInfo, err error) { + // Check the module path and version. + // If the version is a query, resolve it to a canonical version. + m = moduleInfo{modPath: modPath} + if err := checkModPath(modPath); err != nil { + return moduleInfo{}, err + } + + var ok bool + _, m.modPathMajor, ok = module.SplitPathVersion(modPath) + if !ok { + // we just validated the path above. + panic(fmt.Sprintf("could not find version suffix in module path %q", modPath)) + } + + if version == "none" { + // We don't have a base version to compare against. + m.version = "none" + return m, nil + } + if version == "" { + // Unspecified version: use the highest version below max. + m.versionInferred = true + if m.version, err = inferBaseVersion(ctx, modPath, max); err != nil { + return moduleInfo{}, err + } + if m.version == "none" { + return m, nil + } + } else if version != module.CanonicalVersion(version) { + // Version query: find the real version. + m.versionQuery = version + if m.version, err = queryVersion(ctx, modPath, version); err != nil { + return moduleInfo{}, err + } + if m.version != "none" && max != "" && semver.Compare(m.version, max) >= 0 { + // TODO(jayconrod): reconsider this comparison for pseudo-versions in + // general. A query might match different pseudo-versions over time, + // depending on ancestor versions, so this might start failing with + // no local change. + return moduleInfo{}, fmt.Errorf("base version %s (%s) must be lower than release version %s", m.version, m.versionQuery, max) + } + } else { + // Canonical version: make sure it matches the module path. + if err := module.CheckPathMajor(version, m.modPathMajor); err != nil { + // TODO(golang.org/issue/39666): don't assume this is the base version + // or that we're comparing across major versions. + return moduleInfo{}, fmt.Errorf("can't compare major versions: base version %s does not belong to module %s", version, modPath) + } + m.version = version + } + + // Download the module into the cache and load the mod file. + // Note that goModPath is $GOMODCACHE/cache/download/$modPath/@v/$version.mod, + // which is not inside modRoot. This is what the go command uses. Even if + // the module didn't have a go.mod file, one will be synthesized there. + v := module.Version{Path: modPath, Version: m.version} + if m.modRoot, m.goModPath, err = downloadModule(ctx, v); err != nil { + return moduleInfo{}, err + } + if m.goModData, err = ioutil.ReadFile(m.goModPath); err != nil { + return moduleInfo{}, err + } + if m.goModFile, err = modfile.ParseLax(m.goModPath, m.goModData, nil); err != nil { + return moduleInfo{}, err + } + if m.goModFile.Module == nil { + return moduleInfo{}, fmt.Errorf("%s: missing module directive", m.goModPath) + } + m.modPath = m.goModFile.Module.Mod.Path + + // Load packages. + tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths, _, err := prepareLoadDir(ctx, nil, m.modPath, m.modRoot, m.version, true) + if err != nil { + return moduleInfo{}, err + } + defer func() { + if rerr := os.RemoveAll(tmpLoadDir); err == nil && rerr != nil { + err = fmt.Errorf("removing temporary load directory: %v", err) + } + }() + + if m.pkgs, _, err = loadPackages(ctx, m.modPath, m.modRoot, tmpLoadDir, tmpGoModData, tmpGoSumData, pkgPaths); err != nil { + return moduleInfo{}, err + } + + // Calculate the existing versions. + ev, err := existingVersions(ctx, m.modPath, tmpLoadDir) + if err != nil { + return moduleInfo{}, err + } + m.existingVersions = ev + + return m, nil +} + +// makeReleaseReport returns a report comparing the current version of a +// module with a previously released version. The report notes any backward +// compatible and incompatible changes in the module's public API. It also +// diagnoses common problems, such as go.mod or go.sum being incomplete. +// The report recommends or validates a release version and indicates a +// version control tag to use (with an appropriate prefix, for modules not +// in the repository root directory). +func makeReleaseReport(ctx context.Context, base, release moduleInfo) (report, error) { + // Compare each pair of packages. + // Ignore internal packages. + // If we don't have a base version to compare against just check the new + // packages for errors. + shouldCompare := base.version != "none" + isInternal := func(modPath, pkgPath string) bool { + if !hasPathPrefix(pkgPath, modPath) { + panic(fmt.Sprintf("package %s not in module %s", pkgPath, modPath)) + } + for pkgPath != modPath { + if path.Base(pkgPath) == "internal" { + return true + } + pkgPath = path.Dir(pkgPath) + } + return false + } + r := report{ + base: base, + release: release, + } + for _, pair := range zipPackages(base.modPath, base.pkgs, release.modPath, release.pkgs) { + basePkg, releasePkg := pair.base, pair.release + switch { + case releasePkg == nil: + // Package removed + if internal := isInternal(base.modPath, basePkg.PkgPath); !internal || len(basePkg.Errors) > 0 { + pr := packageReport{ + path: basePkg.PkgPath, + baseErrors: basePkg.Errors, + } + if !internal { + pr.Report = apidiff.Report{ + Changes: []apidiff.Change{{ + Message: "package removed", + Compatible: false, + }}, + } + } + r.addPackage(pr) + } + + case basePkg == nil: + // Package added + if internal := isInternal(release.modPath, releasePkg.PkgPath); !internal && shouldCompare || len(releasePkg.Errors) > 0 { + pr := packageReport{ + path: releasePkg.PkgPath, + releaseErrors: releasePkg.Errors, + } + if !internal && shouldCompare { + // If we aren't comparing against a base version, don't say + // "package added". Only report packages with errors. + pr.Report = apidiff.Report{ + Changes: []apidiff.Change{{ + Message: "package added", + Compatible: true, + }}, + } + } + r.addPackage(pr) + } + + default: + // Matched packages + // Both packages are internal or neither; we only consider path components + // after the module path. + internal := isInternal(release.modPath, releasePkg.PkgPath) + if !internal && basePkg.Name != "main" && releasePkg.Name != "main" { + pr := packageReport{ + path: basePkg.PkgPath, + baseErrors: basePkg.Errors, + releaseErrors: releasePkg.Errors, + Report: apidiff.Changes(basePkg.Types, releasePkg.Types), + } + r.addPackage(pr) + } + } + } + + if r.canVerifyReleaseVersion() { + if release.version == "" { + r.suggestReleaseVersion() + } else { + r.validateReleaseVersion() + } + } + + return r, nil +} + +// existingVersions returns the versions that already exist for the given +// modPath. +func existingVersions(ctx context.Context, modPath, modRoot string) (versions []string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("listing versions of %s: %w", modPath, err) + } + }() + + type listVersions struct { + Versions []string + } + cmd := exec.CommandContext(ctx, "go", "list", "-json", "-m", "-versions", modPath) + cmd.Env = copyEnv(ctx, cmd.Env) + cmd.Dir = modRoot + out, err := cmd.Output() + if err != nil { + return nil, cleanCmdError(err) + } + if len(out) == 0 { + return nil, nil + } + + var lv listVersions + if err := json.Unmarshal(out, &lv); err != nil { + return nil, err + } + return lv.Versions, nil +} + +// findRepoRoot finds the root directory of the repository that contains dir. +// findRepoRoot returns "" if it can't find the repository root. +func findRepoRoot(dir string) string { + vcsDirs := []string{".git", ".hg", ".svn", ".bzr"} + d := filepath.Clean(dir) + for { + for _, vcsDir := range vcsDirs { + if _, err := os.Stat(filepath.Join(d, vcsDir)); err == nil { + return d + } + } + parent := filepath.Dir(d) + if parent == d { + return "" + } + d = parent + } +} + +// findModuleRoot finds the root directory of the module that contains dir. +func findModuleRoot(dir string) (string, error) { + d := filepath.Clean(dir) + for { + if fi, err := os.Stat(filepath.Join(d, "go.mod")); err == nil && !fi.IsDir() { + return dir, nil + } + parent := filepath.Dir(d) + if parent == d { + break + } + d = parent + } + return "", fmt.Errorf("%s: cannot find go.mod file", dir) +} + +// checkModPath is like golang.org/x/mod/module.CheckPath, but it returns +// friendlier error messages for common mistakes. +// +// TODO(jayconrod): update module.CheckPath and delete this function. +func checkModPath(modPath string) error { + if path.IsAbs(modPath) || filepath.IsAbs(modPath) { + // TODO(jayconrod): improve error message in x/mod instead of checking here. + return fmt.Errorf("module path %q must not be an absolute path.\nIt must be an address where your module may be found.", modPath) + } + if suffix := dirMajorSuffix(modPath); suffix == "v0" || suffix == "v1" { + return fmt.Errorf("module path %q has major version suffix %q.\nA major version suffix is only allowed for v2 or later.", modPath, suffix) + } else if strings.HasPrefix(suffix, "v0") { + return fmt.Errorf("module path %q has major version suffix %q.\nA major version may not have a leading zero.", modPath, suffix) + } else if strings.ContainsRune(suffix, '.') { + return fmt.Errorf("module path %q has major version suffix %q.\nA major version may not contain dots.", modPath, suffix) + } + return module.CheckPath(modPath) +} + +// inferBaseVersion returns an appropriate base version if one was not specified +// explicitly. +// +// If max is not "", inferBaseVersion returns the highest available release +// version of the module lower than max. Otherwise, inferBaseVersion returns the +// highest available release version. Pre-release versions are not considered. +// If there is no available version, and max appears to be the first release +// version (for example, "v0.1.0", "v2.0.0"), "none" is returned. +func inferBaseVersion(ctx context.Context, modPath, max string) (baseVersion string, err error) { + defer func() { + if err != nil { + err = &baseVersionError{err: err, modPath: modPath} + } + }() + + versions, err := loadVersions(ctx, modPath) + if err != nil { + return "", err + } + + for i := len(versions) - 1; i >= 0; i-- { + v := versions[i] + if semver.Prerelease(v) == "" && + (max == "" || semver.Compare(v, max) < 0) { + return v, nil + } + } + + if max == "" || maybeFirstVersion(max) { + return "none", nil + } + return "", fmt.Errorf("no versions found lower than %s", max) +} + +// queryVersion returns the canonical version for a given module version query. +func queryVersion(ctx context.Context, modPath, query string) (resolved string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("could not resolve version %s@%s: %w", modPath, query, err) + } + }() + if query == "upgrade" || query == "patch" { + return "", errors.New("query is based on requirements in main go.mod file") + } + + tmpDir, err := ioutil.TempDir("", "") + if err != nil { + return "", err + } + defer func() { + if rerr := os.Remove(tmpDir); rerr != nil && err == nil { + err = rerr + } + }() + arg := modPath + "@" + query + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-f", "{{.Version}}", "--", arg) + cmd.Env = copyEnv(ctx, cmd.Env) + cmd.Dir = tmpDir + cmd.Env = append(cmd.Env, "GO111MODULE=on") + out, err := cmd.Output() + if err != nil { + return "", cleanCmdError(err) + } + return strings.TrimSpace(string(out)), nil +} + +// loadVersions loads the list of versions for the given module using +// 'go list -m -versions'. The returned versions are sorted in ascending +// semver order. +func loadVersions(ctx context.Context, modPath string) (versions []string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("could not load versions for %s: %v", modPath, err) + } + }() + + tmpDir, err := ioutil.TempDir("", "") + if err != nil { + return nil, err + } + defer func() { + if rerr := os.Remove(tmpDir); rerr != nil && err == nil { + err = rerr + } + }() + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-versions", "--", modPath) + cmd.Env = copyEnv(ctx, cmd.Env) + cmd.Dir = tmpDir + out, err := cmd.Output() + if err != nil { + return nil, cleanCmdError(err) + } + versions = strings.Fields(string(out)) + if len(versions) > 0 { + versions = versions[1:] // skip module path + } + + // Sort versions defensively. 'go list -m -versions' should always returns + // a sorted list of versions, but it's fast and easy to sort them here, too. + sort.Slice(versions, func(i, j int) bool { + return semver.Compare(versions[i], versions[j]) < 0 + }) + return versions, nil +} + +// maybeFirstVersion returns whether v appears to be the first version +// of a module. +func maybeFirstVersion(v string) bool { + major, minor, patch, _, _, err := parseVersion(v) + if err != nil { + return false + } + if major == "0" { + return minor == "0" && patch == "0" || + minor == "0" && patch == "1" || + minor == "1" && patch == "0" + } + return minor == "0" && patch == "0" +} + +// dirMajorSuffix returns a major version suffix for a slash-separated path. +// For example, for the path "foo/bar/v2", dirMajorSuffix would return "v2". +// If no major version suffix is found, "" is returned. +// +// dirMajorSuffix is less strict than module.SplitPathVersion so that incorrect +// suffixes like "v0", "v02", "v1.2" can be detected. It doesn't handle +// special cases for gopkg.in paths. +func dirMajorSuffix(path string) string { + i := len(path) + for i > 0 && ('0' <= path[i-1] && path[i-1] <= '9') || path[i-1] == '.' { + i-- + } + if i <= 1 || i == len(path) || path[i-1] != 'v' || (i > 1 && path[i-2] != '/') { + return "" + } + return path[i-1:] +} + +// copyModuleToTempDir copies module files from modRoot to a subdirectory of +// scratchDir. Submodules, vendor directories, and irregular files are excluded. +// An error is returned if the module contains any files or directories that +// can't be included in a module zip file (due to special characters, +// excessive sizes, etc.). +func copyModuleToTempDir(repoRoot, modPath, modRoot string) (dir string, err error) { + // Generate a fake version consistent with modPath. We need a canonical + // version to create a zip file. + version := "v0.0.0-gorelease" + _, majorPathSuffix, _ := module.SplitPathVersion(modPath) + if majorPathSuffix != "" { + version = majorPathSuffix[1:] + ".0.0-gorelease" + } + m := module.Version{Path: modPath, Version: version} + + zipFile, err := ioutil.TempFile("", "gorelease-*.zip") + if err != nil { + return "", err + } + defer func() { + zipFile.Close() + os.Remove(zipFile.Name()) + }() + + dir, err = ioutil.TempDir("", "gorelease") + if err != nil { + return "", err + } + defer func() { + if err != nil { + os.RemoveAll(dir) + dir = "" + } + }() + + var fallbackToDir bool + if repoRoot != "" { + var err error + fallbackToDir, err = tryCreateFromVCS(zipFile, m, modRoot, repoRoot) + if err != nil { + return "", err + } + } + + if repoRoot == "" || fallbackToDir { + // Not a recognised repo: fall back to creating from dir. + if err := zip.CreateFromDir(zipFile, m, modRoot); err != nil { + var e zip.FileErrorList + if errors.As(err, &e) { + return "", e + } + return "", err + } + } + + if err := zipFile.Close(); err != nil { + return "", err + } + if err := zip.Unzip(dir, m, zipFile.Name()); err != nil { + return "", err + } + return dir, nil +} + +// tryCreateFromVCS tries to create a module zip file from VCS. If it succeeds, +// it returns fallBackToDir false and a nil err. If it fails in a recoverable +// way, it returns fallBackToDir true and a nil err. If it fails in an +// unrecoverable way, it returns a non-nil err. +func tryCreateFromVCS(zipFile io.Writer, m module.Version, modRoot, repoRoot string) (fallbackToDir bool, _ error) { + // We recognised a repo: create from VCS. + if !hasFilePathPrefix(modRoot, repoRoot) { + panic(fmt.Sprintf("repo root %q is not a prefix of mod root %q", repoRoot, modRoot)) + } + hasUncommitted, err := hasGitUncommittedChanges(repoRoot) + if err != nil { + // Fallback to CreateFromDir. + return true, nil + } + if hasUncommitted { + return false, fmt.Errorf("repo %s has uncommitted changes", repoRoot) + } + modRel := filepath.ToSlash(trimFilePathPrefix(modRoot, repoRoot)) + if err := zip.CreateFromVCS(zipFile, m, repoRoot, "HEAD", modRel); err != nil { + var fel zip.FileErrorList + if errors.As(err, &fel) { + return false, fel + } + var uve *zip.UnrecognizedVCSError + if errors.As(err, &uve) { + // Fallback to CreateFromDir. + return true, nil + } + return false, err + } + // Success! + return false, nil +} + +// downloadModule downloads a specific version of a module to the +// module cache using 'go mod download'. +func downloadModule(ctx context.Context, m module.Version) (modRoot, goModPath string, err error) { + defer func() { + if err != nil { + err = &downloadError{m: m, err: cleanCmdError(err)} + } + }() + + // Run 'go mod download' from a temporary directory to avoid needing to load + // go.mod from gorelease's working directory (or a parent). + // go.mod may be broken, and we don't need it. + // TODO(golang.org/issue/36812): 'go mod download' reads go.mod even though + // we don't need information about the main module or the build list. + // If it didn't read go.mod in this case, we wouldn't need a temp directory. + tmpDir, err := ioutil.TempDir("", "gorelease-download") + if err != nil { + return "", "", err + } + defer os.Remove(tmpDir) + cmd := exec.CommandContext(ctx, "go", "mod", "download", "-json", "--", m.Path+"@"+m.Version) + cmd.Env = copyEnv(ctx, cmd.Env) + cmd.Dir = tmpDir + out, err := cmd.Output() + var xerr *exec.ExitError + if err != nil { + var ok bool + if xerr, ok = err.(*exec.ExitError); !ok { + return "", "", err + } + } + + // If 'go mod download' exited unsuccessfully but printed well-formed JSON + // with an error, return that error. + parsed := struct{ Dir, GoMod, Error string }{} + if jsonErr := json.Unmarshal(out, &parsed); jsonErr != nil { + if xerr != nil { + return "", "", cleanCmdError(xerr) + } + return "", "", jsonErr + } + if parsed.Error != "" { + return "", "", errors.New(parsed.Error) + } + if xerr != nil { + return "", "", cleanCmdError(xerr) + } + return parsed.Dir, parsed.GoMod, nil +} + +// prepareLoadDir creates a temporary directory and a go.mod file that requires +// the module being loaded. go.sum is copied if present. It also creates a .go +// file that imports every package in the given modPath. This temporary module +// is useful for two reasons. First, replace and exclude directives from the +// target module aren't applied, so we have the same view as a dependent module. +// Second, we can run commands like 'go get' without modifying the original +// go.mod and go.sum files. +// +// modFile is the pre-parsed go.mod file. If non-nil, its requirements and +// go version will be copied so that incomplete and out-of-date requirements +// may be reported later. +// +// modPath is the module's path. +// +// modRoot is the module's root directory. +// +// version is the version of the module being loaded. If must be canonical +// for modules loaded from the cache. Otherwise, it may be empty (for example, +// when no release version is proposed). +// +// cached indicates whether the module is being loaded from the module cache. +// If cached is true, then the module lives in the cache at +// $GOMODCACHE/$modPath@$version/. Its go.mod file is at +// $GOMODCACHE/cache/download/$modPath/@v/$version.mod. It must be referenced +// with a simple require. A replace directive won't work because it may not have +// a go.mod file in modRoot. +// If cached is false, then modRoot is somewhere outside the module cache +// (ex /tmp). We'll reference it with a local replace directive. It must have a +// go.mod file in modRoot. +// +// dir is the location of the temporary directory. +// +// goModData and goSumData are the contents of the go.mod and go.sum files, +// respectively. +// +// pkgPaths are the import paths of the module being loaded, including the path +// to any main packages (as if they were importable). +func prepareLoadDir(ctx context.Context, modFile *modfile.File, modPath, modRoot, version string, cached bool) (dir string, goModData, goSumData []byte, pkgPaths []string, diagnostics []string, err error) { + defer func() { + if err != nil { + if cached { + err = fmt.Errorf("preparing to load packages for %s@%s: %w", modPath, version, err) + } else { + err = fmt.Errorf("preparing to load packages for %s: %w", modPath, err) + } + } + }() + + if module.Check(modPath, version) != nil { + // If no version is proposed or if the version isn't valid, use a fake + // version that matches the module's major version suffix. If the version + // is invalid, that will be reported elsewhere. + version = "v0.0.0-gorelease" + if _, pathMajor, _ := module.SplitPathVersion(modPath); pathMajor != "" { + version = pathMajor[1:] + ".0.0-gorelease" + } + } + + dir, err = ioutil.TempDir("", "gorelease-load") + if err != nil { + return "", nil, nil, nil, nil, err + } + + f := &modfile.File{} + f.AddModuleStmt("gorelease-load-module") + f.AddRequire(modPath, version) + if !cached { + f.AddReplace(modPath, version, modRoot, "") + } + if modFile != nil { + if modFile.Go != nil { + f.AddGoStmt(modFile.Go.Version) + } + for _, r := range modFile.Require { + f.AddRequire(r.Mod.Path, r.Mod.Version) + } + } + goModData, err = f.Format() + if err != nil { + return "", nil, nil, nil, nil, err + } + if err := ioutil.WriteFile(filepath.Join(dir, "go.mod"), goModData, 0666); err != nil { + return "", nil, nil, nil, nil, err + } + + goSumData, err = ioutil.ReadFile(filepath.Join(modRoot, "go.sum")) + if err != nil && !os.IsNotExist(err) { + return "", nil, nil, nil, nil, err + } + if err := ioutil.WriteFile(filepath.Join(dir, "go.sum"), goSumData, 0666); err != nil { + return "", nil, nil, nil, nil, err + } + + // Add a .go file with requirements, so that `go get` won't blat + // requirements. + fakeImports := &strings.Builder{} + fmt.Fprint(fakeImports, "package tmp\n") + imps, err := collectImportPaths(modPath, modRoot) + if err != nil { + return "", nil, nil, nil, nil, err + } + for _, imp := range imps { + fmt.Fprintf(fakeImports, "import _ %q\n", imp) + } + if err := ioutil.WriteFile(filepath.Join(dir, "tmp.go"), []byte(fakeImports.String()), 0666); err != nil { + return "", nil, nil, nil, nil, err + } + + // Add missing requirements. + cmd := exec.CommandContext(ctx, "go", "get", "-d", ".") + cmd.Env = copyEnv(ctx, cmd.Env) + cmd.Dir = dir + if _, err := cmd.Output(); err != nil { + return "", nil, nil, nil, nil, fmt.Errorf("looking for missing dependencies: %w", cleanCmdError(err)) + } + + // Report new requirements in go.mod. + goModPath := filepath.Join(dir, "go.mod") + loadReqs := func(data []byte) (reqs []module.Version, err error) { + modFile, err := modfile.ParseLax(goModPath, data, nil) + if err != nil { + return nil, err + } + for _, r := range modFile.Require { + reqs = append(reqs, r.Mod) + } + return reqs, nil + } + + oldReqs, err := loadReqs(goModData) + if err != nil { + return "", nil, nil, nil, nil, err + } + newGoModData, err := ioutil.ReadFile(goModPath) + if err != nil { + return "", nil, nil, nil, nil, err + } + newReqs, err := loadReqs(newGoModData) + if err != nil { + return "", nil, nil, nil, nil, err + } + + oldMap := make(map[module.Version]bool) + for _, req := range oldReqs { + oldMap[req] = true + } + var missing []module.Version + for _, req := range newReqs { + // Ignore cyclic imports, since a module never needs to require itself. + if req.Path == modPath { + continue + } + if !oldMap[req] { + missing = append(missing, req) + } + } + + if len(missing) > 0 { + var missingReqs []string + for _, m := range missing { + missingReqs = append(missingReqs, m.String()) + } + diagnostics = append(diagnostics, fmt.Sprintf("go.mod: the following requirements are needed\n\t%s\nRun 'go mod tidy' to add missing requirements.", strings.Join(missingReqs, "\n\t"))) + return dir, goModData, goSumData, imps, diagnostics, nil + } + + // Cached modules may have no go.sum. + // We skip comparison because a downloaded module is outside the user's + // control. + if !cached { + // Check if 'go get' added new hashes to go.sum. + goSumPath := filepath.Join(dir, "go.sum") + newGoSumData, err := ioutil.ReadFile(goSumPath) + if err != nil { + if !os.IsNotExist(err) { + return "", nil, nil, nil, nil, err + } + // If the sum doesn't exist, that's ok: we'll treat "no go.sum" like + // "empty go.sum". + } + + if !sumsMatchIgnoringPath(string(goSumData), string(newGoSumData), modPath) { + diagnostics = append(diagnostics, "go.sum: one or more sums are missing. Run 'go mod tidy' to add missing sums.") + } + } + + return dir, goModData, goSumData, imps, diagnostics, nil +} + +// sumsMatchIgnoringPath checks whether the two sums match. It ignores any lines +// which contains the given modPath. +func sumsMatchIgnoringPath(sum1, sum2, modPathToIgnore string) bool { + lines1 := make(map[string]bool) + for _, line := range strings.Split(string(sum1), "\n") { + if line == "" { + continue + } + lines1[line] = true + } + for _, line := range strings.Split(string(sum2), "\n") { + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 1 { + panic(fmt.Sprintf("go.sum malformed: unexpected line %s", line)) + } + if parts[0] == modPathToIgnore { + continue + } + + if !lines1[line] { + return false + } + } + + lines2 := make(map[string]bool) + for _, line := range strings.Split(string(sum2), "\n") { + if line == "" { + continue + } + lines2[line] = true + } + for _, line := range strings.Split(string(sum1), "\n") { + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 1 { + panic(fmt.Sprintf("go.sum malformed: unexpected line %s", line)) + } + if parts[0] == modPathToIgnore { + continue + } + + if !lines2[line] { + return false + } + } + + return true +} + +// collectImportPaths visits the given root and traverses its directories +// recursively, collecting the import paths of all importable packages in each +// directory along the way. +// +// modPath is the module path. +// root is the root directory of the module to collect imports for (the root +// of the modPath module). +// +// Note: the returned importPaths will include main if it exists in root. +func collectImportPaths(modPath, root string) (importPaths []string, _ error) { + err := filepath.Walk(root, func(walkPath string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + + // Avoid .foo, _foo, and testdata subdirectory trees. + if !fi.IsDir() { + return nil + } + base := filepath.Base(walkPath) + if strings.HasPrefix(base, ".") || strings.HasPrefix(base, "_") || base == "testdata" || base == "internal" { + return filepath.SkipDir + } + + p, err := build.Default.ImportDir(walkPath, 0) + if err != nil { + if nogoErr := (*build.NoGoError)(nil); errors.As(err, &nogoErr) { + // No .go files found in directory. That's ok, we'll keep + // searching. + return nil + } + return err + } + + // Construct the import path. + importPath := path.Join(modPath, filepath.ToSlash(trimFilePathPrefix(p.Dir, root))) + importPaths = append(importPaths, importPath) + + return nil + }) + if err != nil { + return nil, fmt.Errorf("listing packages in %s: %v", root, err) + } + + return importPaths, nil +} + +// loadPackages returns a list of all packages in the module modPath, sorted by +// package path. modRoot is the module root directory, but packages are loaded +// from loadDir, which must contain go.mod and go.sum containing goModData and +// goSumData. +// +// We load packages from a temporary external module so that replace and exclude +// directives are not applied. The loading process may also modify go.mod and +// go.sum, and we want to detect and report differences. +// +// Package loading errors will be returned in the Errors field of each package. +// Other diagnostics (such as the go.sum file being incomplete) will be +// returned through diagnostics. +// err will be non-nil in case of a fatal error that prevented packages +// from being loaded. +func loadPackages(ctx context.Context, modPath, modRoot, loadDir string, goModData, goSumData []byte, pkgPaths []string) (pkgs []*packages.Package, diagnostics []string, err error) { + // Load packages. + // TODO(jayconrod): if there are errors loading packages in the release + // version, try loading in the release directory. Errors there would imply + // that packages don't load without replace / exclude directives. + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedTypes | packages.NeedImports | packages.NeedDeps, + Dir: loadDir, + Context: ctx, + } + cfg.Env = copyEnv(ctx, cfg.Env) + if len(pkgPaths) > 0 { + pkgs, err = packages.Load(cfg, pkgPaths...) + if err != nil { + return nil, nil, err + } + } + + // Sort the returned packages by path. + // packages.Load makes no guarantee about the order of returned packages. + sort.Slice(pkgs, func(i, j int) bool { + return pkgs[i].PkgPath < pkgs[j].PkgPath + }) + + // Trim modRoot from file paths in errors. + prefix := modRoot + string(os.PathSeparator) + for _, pkg := range pkgs { + for i := range pkg.Errors { + pkg.Errors[i].Pos = strings.TrimPrefix(pkg.Errors[i].Pos, prefix) + } + } + + return pkgs, diagnostics, nil +} + +type packagePair struct { + base, release *packages.Package +} + +// zipPackages combines two lists of packages, sorted by package path, +// and returns a sorted list of pairs of packages with matching paths. +// If a package is in one list but not the other (because it was added or +// removed between releases), a pair will be returned with a nil +// base or release field. +func zipPackages(baseModPath string, basePkgs []*packages.Package, releaseModPath string, releasePkgs []*packages.Package) []packagePair { + baseIndex, releaseIndex := 0, 0 + var pairs []packagePair + for baseIndex < len(basePkgs) || releaseIndex < len(releasePkgs) { + var basePkg, releasePkg *packages.Package + var baseSuffix, releaseSuffix string + if baseIndex < len(basePkgs) { + basePkg = basePkgs[baseIndex] + baseSuffix = trimPathPrefix(basePkg.PkgPath, baseModPath) + } + if releaseIndex < len(releasePkgs) { + releasePkg = releasePkgs[releaseIndex] + releaseSuffix = trimPathPrefix(releasePkg.PkgPath, releaseModPath) + } + + var pair packagePair + if basePkg != nil && (releasePkg == nil || baseSuffix < releaseSuffix) { + // Package removed + pair = packagePair{basePkg, nil} + baseIndex++ + } else if releasePkg != nil && (basePkg == nil || releaseSuffix < baseSuffix) { + // Package added + pair = packagePair{nil, releasePkg} + releaseIndex++ + } else { + // Matched packages. + pair = packagePair{basePkg, releasePkg} + baseIndex++ + releaseIndex++ + } + pairs = append(pairs, pair) + } + return pairs +} + +// findSelectedVersion returns the highest version of the given modPath at +// modDir, if a module cycle exists. modDir should be a writable directory +// containing the go.mod for modPath. +// +// If no module cycle exists, it returns empty string. +func findSelectedVersion(ctx context.Context, modDir, modPath string) (latestVersion string, err error) { + defer func() { + if err != nil { + err = fmt.Errorf("could not find selected version for %s: %v", modPath, err) + } + }() + + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-f", "{{.Version}}", "--", modPath) + cmd.Env = copyEnv(ctx, cmd.Env) + cmd.Dir = modDir + out, err := cmd.Output() + if err != nil { + return "", cleanCmdError(err) + } + return strings.TrimSpace(string(out)), nil +} + +func copyEnv(ctx context.Context, current []string) []string { + env, ok := ctx.Value("env").([]string) + if !ok { + return current + } + clone := make([]string, len(env)) + copy(clone, env) + return clone +} + +// loadRetractions lists all retracted deps found at the modRoot. +func loadRetractions(ctx context.Context, modRoot string) ([]string, error) { + cmd := exec.CommandContext(ctx, "go", "list", "-json", "-m", "-u", "all") + if env, ok := ctx.Value("env").([]string); ok { + cmd.Env = env + } + cmd.Dir = modRoot + out, err := cmd.Output() + if err != nil { + return nil, cleanCmdError(err) + } + + var retracted []string + type message struct { + Path string + Version string + Retracted []string + } + + dec := json.NewDecoder(bytes.NewBuffer(out)) + for { + var m message + if err := dec.Decode(&m); err == io.EOF { + break + } else if err != nil { + return nil, err + } + if len(m.Retracted) == 0 { + continue + } + rationale, ok := shortRetractionRationale(m.Retracted) + if ok { + retracted = append(retracted, fmt.Sprintf("required module %s@%s retracted by module author: %s", m.Path, m.Version, rationale)) + } else { + retracted = append(retracted, fmt.Sprintf("required module %s@%s retracted by module author", m.Path, m.Version)) + } + } + + return retracted, nil +} + +// ShortRetractionRationale returns a retraction rationale string that is safe +// to print in a terminal. It returns hard-coded strings if the rationale +// is empty, too long, or contains non-printable characters. +// +// It returns true if the rationale was printable, and false if it was not (too +// long, contains graphics, etc). +func shortRetractionRationale(rationales []string) (string, bool) { + if len(rationales) == 0 { + return "", false + } + rationale := rationales[0] + + const maxRationaleBytes = 500 + if i := strings.Index(rationale, "\n"); i >= 0 { + rationale = rationale[:i] + } + rationale = strings.TrimSpace(rationale) + if rationale == "" || rationale == "retracted by module author" { + return "", false + } + if len(rationale) > maxRationaleBytes { + return "", false + } + for _, r := range rationale { + if !unicode.IsGraphic(r) && !unicode.IsSpace(r) { + return "", false + } + } + // NOTE: the go.mod parser rejects invalid UTF-8, so we don't check that here. + return rationale, true +} + +// hasGitUncommittedChanges checks if the given directory has uncommitteed git +// changes. +func hasGitUncommittedChanges(dir string) (bool, error) { + stdout := &bytes.Buffer{} + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + cmd.Stdout = stdout + if err := cmd.Run(); err != nil { + return false, cleanCmdError(err) + } + return stdout.Len() != 0, nil +} |