chore: add mod compatibility testing to google-api-go-client (#3749) * chore: add mod compatibility testing to google-api-go-client This change just copies the go version validation workflow from google-cloud-go to google-api-go-client. * mod tidy * relax header test regexp
diff --git a/.github/workflows/mod_compatibility.yml b/.github/workflows/mod_compatibility.yml new file mode 100644 index 0000000..a61ef05 --- /dev/null +++ b/.github/workflows/mod_compatibility.yml
@@ -0,0 +1,36 @@ +name: validate go modules +# The purpose of this action is to maintain a simple invariant that all the SDKs we release +# supported versions of go in accordance with our policy: +# https://docs.cloud.google.com/go/getting-started/supported-go-versions +# +# In a nutshell, this action runs on PRs to identify any go module files that declare an atypical +# Go release version and flag them to the reviewer for scrutiny. +on: + pull_request: + paths: + - '**/go.mod' + + +permissions: + contents: read + +env: + GOTOOLCHAIN: local + +jobs: + validate_go_version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 1.26.x + - name: Validate go minimum version + id: validate + run: | + cd internal/actions + go run ./cmd/modvalidator --enforced_version=1.26.0 --dir=${{ github.workspace }} +
diff --git a/go.mod b/go.mod index 2cd625e..775d877 100644 --- a/go.mod +++ b/go.mod
@@ -15,6 +15,7 @@ github.com/googleapis/gax-go/v2 v2.24.1 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 + golang.org/x/mod v0.41.0 golang.org/x/net v0.59.0 golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.23.0
diff --git a/go.sum b/go.sum index 6de9ebb..7a58494 100644 --- a/go.sum +++ b/go.sum
@@ -58,6 +58,8 @@ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
diff --git a/header_test.go b/header_test.go index a493d25..c4d99cd 100644 --- a/header_test.go +++ b/header_test.go
@@ -14,8 +14,6 @@ // Files in this package use a BSD-style license. var sentinel = regexp.MustCompile(`(//|#) Copyright \d\d\d\d (Google LLC|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. `) const prefix = "// Copyright"
diff --git a/internal/actions/cmd/modvalidator/README.md b/internal/actions/cmd/modvalidator/README.md new file mode 100644 index 0000000..0bc4639 --- /dev/null +++ b/internal/actions/cmd/modvalidator/README.md
@@ -0,0 +1,7 @@ +# modvalidator + + +modvalidator is a tool that helps ensure we correctly preserve the correct minimum version of the go language as expressed +through go.mod files. + +
diff --git a/internal/actions/cmd/modvalidator/main.go b/internal/actions/cmd/modvalidator/main.go new file mode 100644 index 0000000..38fc298 --- /dev/null +++ b/internal/actions/cmd/modvalidator/main.go
@@ -0,0 +1,93 @@ +// Copyright 2026 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. + +package main + +import ( + "flag" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + + "golang.org/x/mod/modfile" +) + +var ( + dir = flag.String("dir", ".", "the root directory to evaluate") + enforced = flag.String("enforced_version", "", "go version string to be enforced") +) + +func main() { + flag.Parse() + if *enforced == "" { + log.Fatalf("invalid -enforced_version specified. failing.") + } + paths, err := collectModFiles(*dir) + if err != nil { + log.Fatalf("collectModFiles: %v", err) + } + + var failCount, successCount int + for _, p := range paths { + if err := validateModFile(p, *enforced); err != nil { + log.Printf("validation failed for %q: %v", p, err) + failCount = failCount + 1 + } else { + successCount = successCount + 1 + } + } + log.Printf("%d mod files failed, %d succeeded validation", failCount, successCount) + if failCount > 0 { + os.Exit(1) + } +} + +func validateModFile(path string, enforcedVersion string) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + mf, err := modfile.Parse(path, b, nil) + if err != nil { + return err + } + if mf.Go == nil { + return fmt.Errorf("modfile missing Go version") + } + if mf.Go.Version != enforcedVersion { + return fmt.Errorf("version mismatch, modfile set to %q", mf.Go.Version) + } + return nil +} + +// traverse a directory and its subdirectories to collect the paths to all go.mod files. +func collectModFiles(rootDir string) ([]string, error) { + var paths []string + + err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Base(path) == "go.mod" { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return nil, err + } + return paths, nil +}
diff --git a/internal/actions/cmd/modvalidator/main_test.go b/internal/actions/cmd/modvalidator/main_test.go new file mode 100644 index 0000000..3e0558b --- /dev/null +++ b/internal/actions/cmd/modvalidator/main_test.go
@@ -0,0 +1,86 @@ +// Copyright 2026 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. + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateModFile(t *testing.T) { + for _, tc := range []struct { + desc string + fileContents []byte + probeVersion string + wantErr bool + }{ + { + desc: "empty", + wantErr: true, + }, + { + desc: "invalid", + fileContents: []byte( + ` + random gobbledegook + + `), + wantErr: true, + }, + { + desc: "valid but mismatch", + fileContents: []byte( + ` + module foo + + go 1.23.4 + `), + probeVersion: "1.25", + wantErr: true, + }, + { + desc: "validated", + fileContents: []byte( + `module foobarbaz + + go 1.23.4 + `), + probeVersion: "1.23.4", + wantErr: false, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + tmpDir := t.TempDir() + modPath := filepath.Join(tmpDir, "go.mod") + err := os.WriteFile(modPath, tc.fileContents, 0644) + if err != nil { + t.Fatalf("failed to write test data (%q): %v", modPath, err) + } + err = validateModFile(modPath, tc.probeVersion) + if tc.wantErr { + if err == nil { + t.Fatalf("wanted error, got success") + } + } else { + if err != nil { + t.Fatalf("wanted success, got err: %v", err) + } + } + }) + + } + +}