// 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" "encoding/json" "errors" "flag" "fmt" "io" "io/ioutil" "log" "os" "os/exec" "path" "path/filepath" "sort" "strings" "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) } success, err := runRelease(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(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(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 == "" { baseModPath = release.modPath max = releaseVersion } base, err := loadDownloadedModule(baseModPath, baseVersion, max) if err != nil { return false, err } // Compare packages and check for other issues. report, err := makeReleaseReport(base, release) if err != nil { return false, err } if err := report.Text(w); 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 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 } // 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(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(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, err := prepareLoadDir(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(m.modPath, tmpModRoot, tmpLoadDir, tmpGoModData, tmpGoSumData) if err != nil { return moduleInfo{}, err } m.diagnostics = append(m.diagnostics, loadDiagnostics...) 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(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(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(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(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, err := prepareLoadDir(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(m.modPath, m.modRoot, tmpLoadDir, tmpGoModData, tmpGoSumData); err != nil { return moduleInfo{}, err } 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(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 } // 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(modPath, max string) (baseVersion string, err error) { defer func() { if err != nil { err = &baseVersionError{err: err} } }() versions, err := loadVersions(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(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.Command("go", "list", "-m", "-f", "{{.Version}}", "--", arg) cmd.Dir = tmpDir cmd.Env = append(os.Environ(), "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(modPath string) ([]string, error) { 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.Command("go", "list", "-m", "-versions", "--", modPath) cmd.Dir = tmpDir cmd.Env = append(os.Environ(), "GO111MODULE=on") 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(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 = "" } }() 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 } // downloadModule downloads a specific version of a module to the // module cache using 'go mod download'. func downloadModule(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.Command("go", "mod", "download", "-json", "--", m.Path+"@"+m.Version) 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. // // 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. // // 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 true, the module can be referenced with a simple requirement. // If false, the module will be referenced with a local replace directive. func prepareLoadDir(modFile *modfile.File, modPath, modRoot, version string, cached bool) (dir string, goModData, goSumData []byte, err error) { 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, 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, err } if err := ioutil.WriteFile(filepath.Join(dir, "go.mod"), goModData, 0666); err != nil { return "", nil, nil, err } goSumData, err = ioutil.ReadFile(filepath.Join(modRoot, "go.sum")) if err != nil && !os.IsNotExist(err) { return "", nil, nil, err } if err := ioutil.WriteFile(filepath.Join(dir, "go.sum"), goSumData, 0666); err != nil { return "", nil, nil, err } return dir, goModData, goSumData, 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(modPath, modRoot, loadDir string, goModData, goSumData []byte) (pkgs []*packages.Package, diagnostics []string, err error) { // List packages in the module. // We can't just load example.com/mod/... because that might include packages // in nested modules. We also can't filter packages from the output of // packages.Load, since it doesn't tell us which module they came from. // // TODO(golang.org/issue/41456): this command fails in -mod=readonly mode // if sums are missing, which they always are for downloaded modules. In // Go 1.16, -mod=readonly is the default, and -mod=mod may eventually be // removed, so we should avoid -mod=mod here. Lazy loading may also require // changes to temporary module requirements. // // Instead of running this command, we should make a list of importable // packages by walking the directory tree. With such a list, // in prepareLoadDir, we could generate a temporary package that imports // all of them, then 'go get -d' that package to ensure no requirements // or sums are missing. format := fmt.Sprintf(`{{if .Module}}{{if eq .Module.Path %q}}{{.ImportPath}}{{end}}{{end}}`, modPath) cmd := exec.Command("go", "list", "-mod=mod", "-e", "-f", format, "--", modPath+"/...") cmd.Dir = loadDir out, err := cmd.Output() if err != nil { return nil, nil, cleanCmdError(err) } var pkgPaths []string for len(out) > 0 { eol := bytes.IndexByte(out, '\n') if eol < 0 { eol = len(out) } pkgPaths = append(pkgPaths, string(out[:eol])) out = out[eol+1:] } // 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, } 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) } } // Report new requirements in go.mod. goModPath := filepath.Join(loadDir, "go.mod") loadReqs := func(data []byte) ([]string, error) { modFile, err := modfile.ParseLax(goModPath, data, nil) if err != nil { return nil, err } lines := make([]string, len(modFile.Require)) for i, req := range modFile.Require { lines[i] = req.Mod.String() } sort.Strings(lines) return lines, nil } oldReqs, err := loadReqs(goModData) if err != nil { return nil, nil, err } newGoModData, err := ioutil.ReadFile(goModPath) if err != nil { return nil, nil, err } newReqs, err := loadReqs(newGoModData) if err != nil { return nil, nil, err } oldMap := make(map[string]bool) for _, req := range oldReqs { oldMap[req] = true } var missing []string for _, req := range newReqs { if !oldMap[req] { missing = append(missing, req) } } if len(missing) > 0 { 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(missing, "\n\t"))) return pkgs, diagnostics, nil } newGoSumData, err := ioutil.ReadFile(filepath.Join(loadDir, "go.sum")) if err != nil && !os.IsNotExist(err) { return nil, nil, err } if !bytes.Equal(goSumData, newGoSumData) { diagnostics = append(diagnostics, "go.sum: one or more sums are missing.\nRun 'go mod tidy' to add missing sums.") } 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 }