| // 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. |
| |
| package main |
| |
| import ( |
| "fmt" |
| "strings" |
| |
| "golang.org/x/exp/apidiff" |
| "golang.org/x/mod/module" |
| "golang.org/x/mod/semver" |
| "golang.org/x/tools/go/packages" |
| ) |
| |
| // report describes the differences in the public API between two versions |
| // of a module. |
| type report struct { |
| // base contains information about the "old" module version being compared |
| // against. base.version may be "none", indicating there is no base version |
| // (for example, if this is the first release). base.version may not be "". |
| base moduleInfo |
| |
| // release contains information about the version of the module to release. |
| // The version may be set explicitly with -version or suggested using |
| // suggestVersion, in which case release.versionInferred is true. |
| release moduleInfo |
| |
| // packages is a list of package reports, describing the differences |
| // for individual packages, sorted by package path. |
| packages []packageReport |
| |
| // versionInvalid explains why the proposed or suggested version is not valid. |
| versionInvalid *versionMessage |
| |
| // haveCompatibleChanges is true if there are any backward-compatible |
| // changes in non-internal packages. |
| haveCompatibleChanges bool |
| |
| // haveIncompatibleChanges is true if there are any backward-incompatible |
| // changes in non-internal packages. |
| haveIncompatibleChanges bool |
| |
| // haveBaseErrors is true if there were errors loading packages |
| // in the base version. |
| haveBaseErrors bool |
| |
| // haveReleaseErrors is true if there were errors loading packages |
| // in the release version. |
| haveReleaseErrors bool |
| } |
| |
| // String returns a human-readable report that lists errors, compatible changes, |
| // and incompatible changes in each package. If releaseVersion is set, the |
| // report states whether releaseVersion is valid (and why). If releaseVersion is |
| // not set, it suggests a new version. |
| func (r *report) String() string { |
| buf := &strings.Builder{} |
| for _, p := range r.packages { |
| buf.WriteString(p.String()) |
| } |
| |
| if !r.canVerifyReleaseVersion() { |
| return buf.String() |
| } |
| |
| if len(r.release.diagnostics) > 0 { |
| buf.WriteString("# diagnostics\n") |
| for _, d := range r.release.diagnostics { |
| fmt.Fprintln(buf, d) |
| } |
| buf.WriteByte('\n') |
| } |
| |
| buf.WriteString("# summary\n") |
| baseVersion := r.base.version |
| if r.base.modPath != r.release.modPath { |
| baseVersion = r.base.modPath + "@" + baseVersion |
| } |
| if r.base.versionInferred { |
| fmt.Fprintf(buf, "Inferred base version: %s\n", baseVersion) |
| } else if r.base.versionQuery != "" { |
| fmt.Fprintf(buf, "Base version: %s (%s)\n", baseVersion, r.base.versionQuery) |
| } |
| |
| if r.versionInvalid != nil { |
| fmt.Fprintln(buf, r.versionInvalid) |
| } else if r.release.versionInferred { |
| if r.release.tagPrefix == "" { |
| fmt.Fprintf(buf, "Suggested version: %s\n", r.release.version) |
| } else { |
| fmt.Fprintf(buf, "Suggested version: %[1]s (with tag %[2]s%[1]s)\n", r.release.version, r.release.tagPrefix) |
| } |
| } else if r.release.version != "" { |
| if r.release.tagPrefix == "" { |
| fmt.Fprintf(buf, "%s is a valid semantic version for this release.\n", r.release.version) |
| |
| if semver.Compare(r.release.version, "v0.0.0-99999999999999-zzzzzzzzzzzz") < 0 { |
| fmt.Fprintf(buf, `Note: %s sorts lower in MVS than pseudo-versions, which may be |
| unexpected for users. So, it may be better to choose a different suffix.`, r.release.version) |
| } |
| } else { |
| fmt.Fprintf(buf, "%[1]s (with tag %[2]s%[1]s) is a valid semantic version for this release\n", r.release.version, r.release.tagPrefix) |
| } |
| } |
| |
| if r.versionInvalid == nil && r.haveBaseErrors { |
| fmt.Fprintln(buf, "Errors were found in the base version. Some API changes may be omitted.") |
| } |
| |
| return buf.String() |
| } |
| |
| func (r *report) addPackage(p packageReport) { |
| r.packages = append(r.packages, p) |
| if len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 { |
| // Only count compatible and incompatible changes if there were no errors. |
| // When there are errors, definitions may be missing, and fixes may appear |
| // incompatible when they are not. Changes will still be reported, but |
| // they won't affect version validation or suggestions. |
| for _, c := range p.Changes { |
| if !c.Compatible && len(p.releaseErrors) == 0 { |
| r.haveIncompatibleChanges = true |
| } else if c.Compatible && len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 { |
| r.haveCompatibleChanges = true |
| } |
| } |
| } |
| if len(p.baseErrors) > 0 { |
| r.haveBaseErrors = true |
| } |
| if len(p.releaseErrors) > 0 { |
| r.haveReleaseErrors = true |
| } |
| } |
| |
| // validateReleaseVersion checks whether r.release.version is valid. |
| // If r.release.version is not valid, an error is returned explaining why. |
| // r.release.version must be set. |
| func (r *report) validateReleaseVersion() { |
| if r.release.version == "" { |
| panic("validateVersion called without version") |
| } |
| setNotValid := func(format string, args ...interface{}) { |
| r.versionInvalid = &versionMessage{ |
| message: fmt.Sprintf("%s is not a valid semantic version for this release.", r.release.version), |
| reason: fmt.Sprintf(format, args...), |
| } |
| } |
| |
| if r.haveReleaseErrors { |
| if r.haveReleaseErrors { |
| setNotValid("Errors were found in one or more packages.") |
| return |
| } |
| } |
| |
| // TODO(jayconrod): link to documentation for all of these errors. |
| |
| // Check that the major version matches the module path. |
| _, suffix, ok := module.SplitPathVersion(r.release.modPath) |
| if !ok { |
| setNotValid("%s: could not find version suffix in module path", r.release.modPath) |
| return |
| } |
| if suffix != "" { |
| if suffix[0] != '/' && suffix[0] != '.' { |
| setNotValid("%s: unknown module path version suffix: %q", r.release.modPath, suffix) |
| return |
| } |
| pathMajor := suffix[1:] |
| major := semver.Major(r.release.version) |
| if pathMajor != major { |
| setNotValid(`The major version %s does not match the major version suffix |
| in the module path: %s`, major, r.release.modPath) |
| return |
| } |
| } else if major := semver.Major(r.release.version); major != "v0" && major != "v1" { |
| setNotValid(`The module path does not end with the major version suffix /%s, |
| which is required for major versions v2 or greater.`, major) |
| return |
| } |
| |
| for _, v := range r.base.existingVersions { |
| if semver.Compare(v, r.release.version) == 0 { |
| setNotValid("version %s already exists", v) |
| } |
| } |
| |
| // Check that compatible / incompatible changes are consistent. |
| if semver.Major(r.base.version) == "v0" || r.base.modPath != r.release.modPath { |
| return |
| } |
| if r.haveIncompatibleChanges { |
| setNotValid("There are incompatible changes.") |
| return |
| } |
| if r.haveCompatibleChanges && semver.MajorMinor(r.base.version) == semver.MajorMinor(r.release.version) { |
| setNotValid(`There are compatible changes, but the minor version is not incremented |
| over the base version (%s).`, r.base.version) |
| return |
| } |
| |
| if r.release.highestTransitiveVersion != "" && semver.Compare(r.release.highestTransitiveVersion, r.release.version) > 0 { |
| setNotValid(`Module indirectly depends on a higher version of itself (%s). |
| `, r.release.highestTransitiveVersion) |
| } |
| } |
| |
| // suggestReleaseVersion suggests a new version consistent with observed |
| // changes. |
| func (r *report) suggestReleaseVersion() { |
| setNotValid := func(format string, args ...interface{}) { |
| r.versionInvalid = &versionMessage{ |
| message: "Cannot suggest a release version.", |
| reason: fmt.Sprintf(format, args...), |
| } |
| } |
| setVersion := func(v string) { |
| r.release.version = v |
| r.release.versionInferred = true |
| } |
| |
| if r.base.modPath != r.release.modPath { |
| setNotValid("Base module path is different from release.") |
| return |
| } |
| |
| if r.haveReleaseErrors || r.haveBaseErrors { |
| setNotValid("Errors were found.") |
| return |
| } |
| |
| var major, minor, patch, pre string |
| if r.base.version != "none" { |
| minVersion := r.base.version |
| if r.release.highestTransitiveVersion != "" && semver.Compare(r.release.highestTransitiveVersion, minVersion) > 0 { |
| setNotValid("Module indirectly depends on a higher version of itself (%s) than the base version (%s).", r.release.highestTransitiveVersion, r.base.version) |
| return |
| } |
| |
| var err error |
| major, minor, patch, pre, _, err = parseVersion(minVersion) |
| if err != nil { |
| panic(fmt.Sprintf("could not parse base version: %v", err)) |
| } |
| } |
| |
| if r.haveIncompatibleChanges && r.base.version != "none" && pre == "" && major != "0" { |
| setNotValid("Incompatible changes were detected.") |
| return |
| // TODO(jayconrod): briefly explain how to prepare major version releases |
| // and link to documentation. |
| } |
| |
| // Check whether we're comparing to the latest version of base. |
| // |
| // This could happen further up, but we want the more pressing errors above |
| // to take precedence. |
| var latestForBaseMajor string |
| for _, v := range r.base.existingVersions { |
| if semver.Major(v) != semver.Major(r.base.version) { |
| continue |
| } |
| if latestForBaseMajor == "" || semver.Compare(latestForBaseMajor, v) < 0 { |
| latestForBaseMajor = v |
| } |
| } |
| if latestForBaseMajor != "" && latestForBaseMajor != r.base.version { |
| setNotValid(fmt.Sprintf("Can only suggest a release version when compared against the most recent version of this major: %s.", latestForBaseMajor)) |
| return |
| } |
| |
| if r.base.version == "none" { |
| if _, pathMajor, ok := module.SplitPathVersion(r.release.modPath); !ok { |
| panic(fmt.Sprintf("could not parse module path %q", r.release.modPath)) |
| } else if pathMajor == "" { |
| setVersion("v0.1.0") |
| } else { |
| setVersion(pathMajor[1:] + ".0.0") |
| } |
| return |
| } |
| |
| if pre != "" { |
| // suggest non-prerelease version |
| } else if r.haveCompatibleChanges || (r.haveIncompatibleChanges && major == "0") || r.requirementsChanged() { |
| minor = incDecimal(minor) |
| patch = "0" |
| } else { |
| patch = incDecimal(patch) |
| } |
| setVersion(fmt.Sprintf("v%s.%s.%s", major, minor, patch)) |
| return |
| } |
| |
| // canVerifyReleaseVersion returns true if we can safely suggest a new version |
| // or if we can verify the version passed in with -version is safe to tag. |
| func (r *report) canVerifyReleaseVersion() bool { |
| // For now, return true if the base and release module paths are the same, |
| // ignoring the major version suffix. |
| // TODO(#37562, #39192, #39666, #40267): there are many more situations when |
| // we can't verify a new version. |
| basePath := strings.TrimSuffix(r.base.modPath, r.base.modPathMajor) |
| releasePath := strings.TrimSuffix(r.release.modPath, r.release.modPathMajor) |
| return basePath == releasePath |
| } |
| |
| // requirementsChanged reports whether requirements have changed from base to |
| // version. |
| // |
| // requirementsChanged reports true for, |
| // - A requirement was upgraded to a higher minor version. |
| // - A requirement was added. |
| // - The version of Go was incremented. |
| // |
| // It does not report true when, for example, a requirement was downgraded or |
| // remove. We care more about the former since that might force dependent |
| // modules that have the same dependency to upgrade. |
| func (r *report) requirementsChanged() bool { |
| if r.base.goModFile == nil { |
| // There wasn't a modfile before, and now there is. |
| return true |
| } |
| |
| // baseReqs is a map of module path to MajorMinor of the base module |
| // requirements. |
| baseReqs := make(map[string]string) |
| for _, r := range r.base.goModFile.Require { |
| baseReqs[r.Mod.Path] = r.Mod.Version |
| } |
| |
| for _, r := range r.release.goModFile.Require { |
| if _, ok := baseReqs[r.Mod.Path]; !ok { |
| // A module@version was added to the "require" block between base |
| // and release. |
| return true |
| } |
| if semver.Compare(semver.MajorMinor(r.Mod.Version), semver.MajorMinor(baseReqs[r.Mod.Path])) > 0 { |
| // The version of r.Mod.Path increased from base to release. |
| return true |
| } |
| } |
| |
| if r.release.goModFile.Go != nil && r.base.goModFile.Go != nil { |
| if r.release.goModFile.Go.Version > r.base.goModFile.Go.Version { |
| // The Go version increased from base to release. |
| return true |
| } |
| } |
| |
| return false |
| } |
| |
| // isSuccessful returns true the module appears to be safe to release at the |
| // proposed or suggested version. |
| func (r *report) isSuccessful() bool { |
| return len(r.release.diagnostics) == 0 && r.versionInvalid == nil |
| } |
| |
| type versionMessage struct { |
| message, reason string |
| } |
| |
| func (m versionMessage) String() string { |
| return m.message + "\n" + m.reason + "\n" |
| } |
| |
| // incDecimal returns the decimal string incremented by 1. |
| func incDecimal(decimal string) string { |
| // Scan right to left turning 9s to 0s until you find a digit to increment. |
| digits := []byte(decimal) |
| i := len(digits) - 1 |
| for ; i >= 0 && digits[i] == '9'; i-- { |
| digits[i] = '0' |
| } |
| if i >= 0 { |
| digits[i]++ |
| } else { |
| // digits is all zeros |
| digits[0] = '1' |
| digits = append(digits, '0') |
| } |
| return string(digits) |
| } |
| |
| type packageReport struct { |
| apidiff.Report |
| path string |
| baseErrors, releaseErrors []packages.Error |
| } |
| |
| func (p *packageReport) String() string { |
| if len(p.Changes) == 0 && len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 { |
| return "" |
| } |
| buf := &strings.Builder{} |
| fmt.Fprintf(buf, "# %s\n", p.path) |
| if len(p.baseErrors) > 0 { |
| fmt.Fprintf(buf, "## errors in base version:\n") |
| for _, e := range p.baseErrors { |
| fmt.Fprintln(buf, e) |
| } |
| buf.WriteByte('\n') |
| } |
| if len(p.releaseErrors) > 0 { |
| fmt.Fprintf(buf, "## errors in release version:\n") |
| for _, e := range p.releaseErrors { |
| fmt.Fprintln(buf, e) |
| } |
| buf.WriteByte('\n') |
| } |
| if len(p.Changes) > 0 { |
| var compatible, incompatible []apidiff.Change |
| for _, c := range p.Changes { |
| if c.Compatible { |
| compatible = append(compatible, c) |
| } else { |
| incompatible = append(incompatible, c) |
| } |
| } |
| if len(incompatible) > 0 { |
| fmt.Fprintf(buf, "## incompatible changes\n") |
| for _, c := range incompatible { |
| fmt.Fprintln(buf, c.Message) |
| } |
| } |
| if len(compatible) > 0 { |
| fmt.Fprintf(buf, "## compatible changes\n") |
| for _, c := range compatible { |
| fmt.Fprintln(buf, c.Message) |
| } |
| } |
| buf.WriteByte('\n') |
| } |
| return buf.String() |
| } |
| |
| // parseVersion returns the major, minor, and patch numbers, prerelease text, |
| // and metadata for a given version. |
| // |
| // TODO(jayconrod): extend semver to do this and delete this function. |
| func parseVersion(vers string) (major, minor, patch, pre, meta string, err error) { |
| if !strings.HasPrefix(vers, "v") { |
| return "", "", "", "", "", fmt.Errorf("version %q does not start with 'v'", vers) |
| } |
| base := vers[1:] |
| if i := strings.IndexByte(base, '+'); i >= 0 { |
| meta = base[i+1:] |
| base = base[:i] |
| } |
| if i := strings.IndexByte(base, '-'); i >= 0 { |
| pre = base[i+1:] |
| base = base[:i] |
| } |
| parts := strings.Split(base, ".") |
| if len(parts) != 3 { |
| return "", "", "", "", "", fmt.Errorf("version %q should have three numbers", vers) |
| } |
| major, minor, patch = parts[0], parts[1], parts[2] |
| return major, minor, patch, pre, meta, nil |
| } |