1// Copyright 2021 Google LLC 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15// +build linux darwin 16 17package main 18 19import ( 20 "encoding/json" 21 "flag" 22 "fmt" 23 "io/ioutil" 24 "log" 25 "os" 26 osexec "os/exec" 27 "path" 28 "strings" 29) 30 31// TODO(noahdietz): remove this once the fix in golang.org/x/tools is released. 32// https://github.com/golang/go/issues/44796 33const ignored = "- MaxPublishRequestBytes: value changed from 0.000582077 to 10000000" 34const rootMod = "cloud.google.com/go" 35 36var repoMetadataPath string 37var verbose bool 38var gapic string 39 40func init() { 41 flag.StringVar(&repoMetadataPath, "repo-metadata", "", "path to a repo-metadata-full JSON file [required]") 42 flag.StringVar(&gapic, "gapic", "", "import path of a specific GAPIC to diff") 43 flag.BoolVar(&verbose, "verbose", false, "enable verbose command logging") 44} 45 46func main() { 47 flag.Parse() 48 if repoMetadataPath == "" { 49 log.Fatalln("Missing required flag: -repo-metadata") 50 } 51 52 head, err := exec("git", "log", "-2") 53 if err != nil { 54 log.Fatalln(err) 55 } 56 if checkAllowBreakingChange(head) { 57 return 58 } 59 60 root, err := os.Getwd() 61 if err != nil { 62 log.Fatalln(err) 63 } 64 65 f, err := os.Open(repoMetadataPath) 66 if err != nil { 67 log.Fatalln(err) 68 } 69 defer f.Close() 70 71 var m manifest 72 if err := json.NewDecoder(f).Decode(&m); err != nil { 73 log.Fatalln(err) 74 } 75 76 _, err = exec("go", "install", "golang.org/x/exp/cmd/apidiff@latest") 77 if err != nil { 78 log.Fatalln(err) 79 } 80 81 temp, err := ioutil.TempDir("/tmp", "google-cloud-go-*") 82 if err != nil { 83 log.Fatalln(err) 84 } 85 defer os.RemoveAll(temp) 86 87 _, err = exec("git", "clone", "https://github.com/googleapis/google-cloud-go", temp) 88 if err != nil { 89 log.Fatalln(err) 90 } 91 92 diffs, diffingErrs, err := diffModules(root, temp, m) 93 if err != nil { 94 log.Fatalln(err) 95 } 96 97 if len(diffingErrs) > 0 { 98 fmt.Fprintln(os.Stderr, "The following packages encountered errors:") 99 for imp, err := range diffingErrs { 100 fmt.Fprintf(os.Stderr, "%s: %s\n", imp, err) 101 } 102 } 103 104 if len(diffs) > 0 { 105 fmt.Fprintln(os.Stderr, "The following breaking changes were found:") 106 for imp, d := range diffs { 107 fmt.Fprintf(os.Stderr, "%s:\n%s\n", imp, d) 108 } 109 os.Exit(1) 110 } 111} 112 113// manifestEntry is used for JSON marshaling in manifest. 114// Copied from internal/gapicgen/generator/gapics.go. 115type manifestEntry struct { 116 DistributionName string `json:"distribution_name"` 117 Description string `json:"description"` 118 Language string `json:"language"` 119 ClientLibraryType string `json:"client_library_type"` 120 DocsURL string `json:"docs_url"` 121 ReleaseLevel string `json:"release_level"` 122} 123 124type manifest map[string]manifestEntry 125 126func diffModules(root, baseDir string, m manifest) (map[string]string, map[string]error, error) { 127 diffs := map[string]string{} 128 issues := map[string]error{} 129 130 for imp, entry := range m { 131 if gapic != "" && imp != gapic { 132 continue 133 } 134 135 // Prepare module directory paths relative to the repo root. 136 pkg := strings.TrimPrefix(imp, rootMod+"/") 137 baseModDir := baseDir 138 modDir := root 139 140 // Manual clients are also submodules, so we need to run apidiff in the 141 // submodule. 142 if entry.ClientLibraryType == "manual" { 143 baseModDir = path.Join(baseModDir, pkg) 144 modDir = path.Join(modDir, pkg) 145 } 146 147 // Create apidiff base from repo remote HEAD. 148 base, err := writeBase(m, baseModDir, imp, pkg) 149 if err != nil { 150 issues[imp] = err 151 continue 152 } 153 154 // Diff the current checked out change against remote HEAD base. 155 out, err := diff(m, modDir, imp, pkg, base) 156 if err != nil { 157 issues[imp] = err 158 continue 159 } 160 161 if out != "" && out != ignored { 162 diffs[imp] = out 163 } 164 } 165 166 return diffs, issues, nil 167} 168 169func writeBase(m manifest, baseModDir, imp, pkg string) (string, error) { 170 if err := cd(baseModDir); err != nil { 171 return "", err 172 } 173 174 base := path.Join(baseModDir, "pkg.master") 175 out, err := exec("apidiff", "-w", base, imp) 176 if err != nil && !isSubModErr(out) { 177 return "", err 178 } 179 180 // If there was an issue with loading a submodule, change into that 181 // submodule directory and try again. 182 if isSubModErr(out) { 183 parent := manualParent(m, imp) 184 if parent == pkg { 185 return "", fmt.Errorf("unable to find parent module for %q", imp) 186 } 187 if err := cd(parent); err != nil { 188 return "", err 189 } 190 out, err := exec("apidiff", "-w", base, imp) 191 if err != nil { 192 return "", fmt.Errorf("%s: %s", err, out) 193 } 194 } 195 return base, nil 196} 197 198func diff(m manifest, modDir, imp, pkg, base string) (string, error) { 199 if err := cd(modDir); err != nil { 200 return "", err 201 } 202 out, err := exec("apidiff", "-incompatible", base, imp) 203 if err != nil && !isSubModErr(out) { 204 return "", err 205 } 206 if isSubModErr(out) { 207 parent := manualParent(m, imp) 208 if parent == pkg { 209 return "", fmt.Errorf("unable to find parent module for %q", imp) 210 } 211 if err := cd(parent); err != nil { 212 return "", err 213 } 214 out, err = exec("apidiff", "-incompatible", base, imp) 215 if err != nil { 216 return "", fmt.Errorf("%s: %s", err, out) 217 } 218 } 219 220 return out, err 221} 222 223func checkAllowBreakingChange(commit string) bool { 224 if strings.Contains(commit, "BREAKING CHANGE:") { 225 log.Println("Not running apidiff because description contained tag BREAKING_CHANGE.") 226 return true 227 } 228 229 split := strings.Split(commit, "\n") 230 for _, s := range split { 231 if strings.Contains(s, "!:") || strings.Contains(s, "!(") { 232 log.Println("Not running apidiff because description contained breaking change indicator '!'.") 233 return true 234 } 235 } 236 237 return false 238} 239 240func manualParent(m manifest, imp string) string { 241 pkg := strings.TrimPrefix(imp, rootMod) 242 split := strings.Split(pkg, "/") 243 244 mod := rootMod 245 for _, seg := range split { 246 mod = path.Join(mod, seg) 247 if parent, ok := m[mod]; ok && parent.ClientLibraryType == "manual" { 248 return strings.TrimPrefix(mod, rootMod+"/") 249 } 250 } 251 252 return pkg 253} 254 255func isSubModErr(msg string) bool { 256 return strings.Contains(msg, "missing") || strings.Contains(msg, "required") 257} 258 259func cd(dir string) error { 260 if verbose { 261 log.Printf("+ cd %s\n", dir) 262 } 263 return os.Chdir(dir) 264} 265 266func exec(cmd string, args ...string) (string, error) { 267 if verbose { 268 log.Printf("+ %s %s\n", cmd, strings.Join(args, " ")) 269 } 270 out, err := osexec.Command(cmd, args...).CombinedOutput() 271 return strings.TrimSpace(string(out)), err 272} 273