blob: 72a5585c8079455d8c9d3ea8713df2dc19289986 [file] [log] [blame]
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// +build linux darwin
package main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"os"
osexec "os/exec"
"path"
"strings"
)
// TODO(noahdietz): remove this once the fix in golang.org/x/tools is released.
// https://github.com/golang/go/issues/44796
const ignored = "- MaxPublishRequestBytes: value changed from 0.000582077 to 10000000"
const rootMod = "cloud.google.com/go"
var repoMetadataPath string
var verbose bool
func init() {
flag.StringVar(&repoMetadataPath, "repo-metadata", "", "path to a repo-metadata-full JSON file [required]")
flag.BoolVar(&verbose, "verbose", false, "enable verbose command logging")
}
func main() {
flag.Parse()
if repoMetadataPath == "" {
log.Fatalln("Missing required flag: -repo-metadata")
}
head, err := exec("git", "log", "-2")
if err != nil {
log.Fatalln(err)
}
if checkAllowBreakingChange(head) {
return
}
root, err := os.Getwd()
if err != nil {
log.Fatalln(err)
}
f, err := os.Open(repoMetadataPath)
if err != nil {
log.Fatalln(err)
}
defer f.Close()
var m manifest
if err := json.NewDecoder(f).Decode(&m); err != nil {
log.Fatalln(err)
}
_, err = exec("go", "install", "golang.org/x/exp/cmd/apidiff@latest")
if err != nil {
log.Fatalln(err)
}
temp, err := ioutil.TempDir("/tmp", "google-cloud-go-*")
if err != nil {
log.Fatalln(err)
}
defer os.RemoveAll(temp)
_, err = exec("git", "clone", "https://github.com/googleapis/google-cloud-go", temp)
if err != nil {
log.Fatalln(err)
}
diffs, diffingErrs, err := diffModules(root, temp, m)
if err != nil {
log.Fatalln(err)
}
if len(diffingErrs) > 0 {
fmt.Fprintln(os.Stderr, "The following packages encountered errors:")
for imp, err := range diffingErrs {
fmt.Fprintf(os.Stderr, "%s: %s\n", imp, err)
}
}
if len(diffs) > 0 {
fmt.Fprintln(os.Stderr, "The following breaking changes were found:")
for imp, d := range diffs {
fmt.Fprintf(os.Stderr, "%s:\n%s\n", imp, d)
}
os.Exit(1)
}
}
// manifestEntry is used for JSON marshaling in manifest.
// Copied from internal/gapicgen/generator/gapics.go.
type manifestEntry struct {
DistributionName string `json:"distribution_name"`
Description string `json:"description"`
Language string `json:"language"`
ClientLibraryType string `json:"client_library_type"`
DocsURL string `json:"docs_url"`
ReleaseLevel string `json:"release_level"`
}
type manifest map[string]manifestEntry
func diffModules(root, baseDir string, m manifest) (map[string]string, map[string]error, error) {
diffs := map[string]string{}
issues := map[string]error{}
for imp, entry := range m {
// Prepare module directory paths relative to the repo root.
pkg := strings.TrimPrefix(imp, rootMod+"/")
baseModDir := baseDir
modDir := root
// Manual clients are also submodules, so we need to run apidiff in the
// submodule.
if entry.ClientLibraryType == "manual" {
baseModDir = path.Join(baseModDir, pkg)
modDir = path.Join(modDir, pkg)
}
// Create apidiff base from repo remote HEAD.
base, err := writeBase(m, baseModDir, imp, pkg)
if err != nil {
issues[imp] = err
continue
}
// Diff the current checked out change against remote HEAD base.
out, err := diff(m, modDir, imp, pkg, base)
if err != nil {
issues[imp] = err
continue
}
if out != "" && out != ignored {
diffs[imp] = out
}
}
return diffs, issues, nil
}
func writeBase(m manifest, baseModDir, imp, pkg string) (string, error) {
if err := cd(baseModDir); err != nil {
return "", err
}
base := path.Join(baseModDir, "pkg.master")
out, err := exec("apidiff", "-w", base, imp)
if err != nil && !isSubModErr(out) {
return "", err
}
// If there was an issue with loading a submodule, change into that
// submodule directory and try again.
if isSubModErr(out) {
parent := manualParent(m, imp)
if parent == pkg {
return "", fmt.Errorf("unable to find parent module for %q", imp)
}
if err := cd(parent); err != nil {
return "", err
}
out, err := exec("apidiff", "-w", base, imp)
if err != nil {
return "", fmt.Errorf("%s: %s", err, out)
}
}
return base, nil
}
func diff(m manifest, modDir, imp, pkg, base string) (string, error) {
if err := cd(modDir); err != nil {
return "", err
}
out, err := exec("apidiff", "-incompatible", base, imp)
if err != nil && !isSubModErr(out) {
return "", err
}
if isSubModErr(out) {
parent := manualParent(m, imp)
if parent == pkg {
return "", fmt.Errorf("unable to find parent module for %q", imp)
}
if err := cd(parent); err != nil {
return "", err
}
out, err = exec("apidiff", "-w", base, imp)
if err != nil {
return "", fmt.Errorf("%s: %s", err, out)
}
}
return out, err
}
func checkAllowBreakingChange(commit string) bool {
if strings.Contains(commit, "BREAKING CHANGE:") {
log.Println("Not running apidiff because description contained tag BREAKING_CHANGE.")
return true
}
split := strings.Split(commit, "\n")
for _, s := range split {
if strings.Contains(s, "!:") || strings.Contains(s, "!(") {
log.Println("Not running apidiff because description contained breaking change indicator '!'.")
return true
}
}
return false
}
func manualParent(m manifest, imp string) string {
pkg := strings.TrimPrefix(imp, rootMod)
split := strings.Split(pkg, "/")
mod := rootMod
for _, seg := range split {
mod = path.Join(mod, seg)
if parent, ok := m[mod]; ok && parent.ClientLibraryType == "manual" {
return strings.TrimPrefix(mod, rootMod+"/")
}
}
return pkg
}
func isSubModErr(msg string) bool {
return strings.Contains(msg, "missing") || strings.Contains(msg, "required")
}
func cd(dir string) error {
if verbose {
log.Printf("+ cd %s\n", dir)
}
return os.Chdir(dir)
}
func exec(cmd string, args ...string) (string, error) {
if verbose {
log.Printf("+ %s %s\n", cmd, strings.Join(args, " "))
}
out, err := osexec.Command(cmd, args...).CombinedOutput()
return strings.TrimSpace(string(out)), err
}