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