Skip to content

Commit 8b1ebc2

Browse files
authored
feat(internal/librarian/golang): add bump for Go libraries (#4256)
Add bump for Go libraries For #3616
1 parent 370f4c3 commit 8b1ebc2

9 files changed

Lines changed: 381 additions & 5 deletions

File tree

internal/librarian/bump.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/googleapis/librarian/internal/command"
2424
"github.com/googleapis/librarian/internal/config"
2525
"github.com/googleapis/librarian/internal/git"
26+
"github.com/googleapis/librarian/internal/librarian/golang"
2627
"github.com/googleapis/librarian/internal/librarian/python"
2728
"github.com/googleapis/librarian/internal/librarian/rust"
2829
"github.com/googleapis/librarian/internal/semver"
@@ -198,6 +199,8 @@ func bumpLibrary(ctx context.Context, cfg *config.Config, lib *config.Library, g
198199
switch cfg.Language {
199200
case languageFake:
200201
return fakeBumpLibrary(output, version)
202+
case languageGo:
203+
return golang.Bump(lib, output, version)
201204
case languagePython:
202205
return python.Bump(output, version)
203206
default:

internal/librarian/golang/bump.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2026 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+
// https://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+
package golang
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
"regexp"
22+
23+
"github.com/googleapis/librarian/internal/config"
24+
"github.com/googleapis/librarian/internal/snippetmetadata"
25+
)
26+
27+
var (
28+
internalVersionFile = filepath.Join("internal", "version.go")
29+
versionRegex = regexp.MustCompile(`(const Version = ")([^"]*)(")`)
30+
)
31+
32+
// Bump updates the version number in the library with the given output
33+
// directory.
34+
func Bump(library *config.Library, output, version string) error {
35+
if err := bumpInternalVersion(library, output, version); err != nil {
36+
return err
37+
}
38+
for _, api := range library.APIs {
39+
goAPI := findGoAPI(library, api.Path)
40+
if goAPI == nil {
41+
return fmt.Errorf("could not find Go API associated with %s: %w", api.Path, errGoAPINotFound)
42+
}
43+
snippetDir := snippetDirectory(output, goAPI.ImportPath)
44+
if err := snippetmetadata.UpdateAllLibraryVersions(snippetDir, version); err != nil {
45+
return err
46+
}
47+
}
48+
return nil
49+
}
50+
51+
func bumpInternalVersion(library *config.Library, output, version string) error {
52+
versionFilePath := filepath.Join(output, library.Name, internalVersionFile)
53+
if _, err := os.Stat(versionFilePath); os.IsNotExist(err) {
54+
return nil
55+
}
56+
57+
return findAndReplace(versionFilePath, version)
58+
}
59+
60+
func findAndReplace(path string, version string) error {
61+
content, err := os.ReadFile(path)
62+
if err != nil {
63+
return err
64+
}
65+
result := versionRegex.ReplaceAllString(string(content), `${1}`+version+`${3}`)
66+
return os.WriteFile(path, []byte(result), 0644)
67+
}
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Copyright 2026 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+
// https://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+
package golang
16+
17+
import (
18+
"errors"
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/google/go-cmp/cmp"
24+
"github.com/googleapis/librarian/internal/config"
25+
)
26+
27+
func TestBump(t *testing.T) {
28+
for _, test := range []struct {
29+
name string
30+
initialFiles map[string]string
31+
library *config.Library
32+
version string
33+
wantFiles map[string]string
34+
}{
35+
{
36+
name: "bump internal version",
37+
initialFiles: map[string]string{
38+
"test-lib/internal/version.go": "package internal\n\nconst Version = \"0.1.0\"\n",
39+
},
40+
library: &config.Library{Name: "test-lib"},
41+
version: "0.2.0",
42+
wantFiles: map[string]string{
43+
"test-lib/internal/version.go": "package internal\n\nconst Version = \"0.2.0\"\n",
44+
},
45+
},
46+
{
47+
name: "ignore other files",
48+
initialFiles: map[string]string{
49+
"test-lib/version.go": "package testlib\n\nconst Version = \"0.1.0\"\n",
50+
"test-lib/internal/other.go": "package internal\n\nconst Version = \"0.1.0\"\n",
51+
},
52+
library: &config.Library{Name: "test-lib"},
53+
version: "0.2.0",
54+
wantFiles: map[string]string{
55+
"test-lib/version.go": "package testlib\n\nconst Version = \"0.1.0\"\n",
56+
"test-lib/internal/other.go": "package internal\n\nconst Version = \"0.1.0\"\n",
57+
},
58+
},
59+
{
60+
name: "ignore nested module",
61+
initialFiles: map[string]string{
62+
"test-lib/internal/version.go": "package internal\n\nconst Version = \"0.1.0\"\n",
63+
"test-lib/nested-module/internal/version.go": "package internal\n\nconst Version = \"0.1.0\"\n",
64+
},
65+
library: &config.Library{
66+
Name: "test-lib",
67+
Go: &config.GoModule{
68+
NestedModule: "nested-module",
69+
},
70+
},
71+
version: "0.2.0",
72+
wantFiles: map[string]string{
73+
"test-lib/internal/version.go": "package internal\n\nconst Version = \"0.2.0\"\n",
74+
"test-lib/nested-module/internal/version.go": "package internal\n\nconst Version = \"0.1.0\"\n",
75+
},
76+
},
77+
{
78+
name: "bump snippet metadata",
79+
initialFiles: map[string]string{
80+
"internal/generated/snippets/test-lib/apiv1/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.1.0\"\n }\n}\n",
81+
},
82+
library: &config.Library{
83+
Name: "test-lib",
84+
APIs: []*config.API{
85+
{
86+
Path: "google/test-lib/v1",
87+
},
88+
},
89+
Go: &config.GoModule{
90+
GoAPIs: []*config.GoAPI{
91+
{
92+
ImportPath: "test-lib/apiv1",
93+
Path: "google/test-lib/v1",
94+
},
95+
},
96+
},
97+
},
98+
version: "0.2.0",
99+
wantFiles: map[string]string{
100+
"internal/generated/snippets/test-lib/apiv1/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.2.0\"\n }\n}",
101+
},
102+
},
103+
{
104+
name: "ignore nested module",
105+
initialFiles: map[string]string{
106+
"internal/generated/snippets/test-lib/apiv1/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.1.0\"\n }\n}",
107+
"internal/generated/snippets/test-lib/v2/apiv1/nested/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.1.0\"\n }\n}",
108+
},
109+
library: &config.Library{
110+
Name: "test-lib",
111+
APIs: []*config.API{
112+
{
113+
Path: "google/test-lib/v1",
114+
},
115+
},
116+
Go: &config.GoModule{
117+
GoAPIs: []*config.GoAPI{
118+
{
119+
ImportPath: "test-lib/apiv1",
120+
Path: "google/test-lib/v1",
121+
},
122+
},
123+
},
124+
},
125+
version: "0.2.0",
126+
wantFiles: map[string]string{
127+
"internal/generated/snippets/test-lib/apiv1/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.2.0\"\n }\n}",
128+
"internal/generated/snippets/test-lib/v2/apiv1/nested/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.1.0\"\n }\n}",
129+
},
130+
},
131+
{
132+
name: "bump irregular version",
133+
initialFiles: map[string]string{
134+
"test-lib/internal/version.go": "package internal\n\nconst Version = \"0.1.0-rc1\"\n",
135+
},
136+
library: &config.Library{Name: "test-lib"},
137+
version: "0.2.0",
138+
wantFiles: map[string]string{
139+
"test-lib/internal/version.go": "package internal\n\nconst Version = \"0.2.0\"\n",
140+
},
141+
},
142+
} {
143+
t.Run(test.name, func(t *testing.T) {
144+
output := t.TempDir()
145+
libraryDir := filepath.Join(output, test.library.Name)
146+
if err := os.MkdirAll(libraryDir, 0755); err != nil {
147+
t.Fatal(err)
148+
}
149+
snippetsDir := filepath.Join(output, "internal", "generated", "snippets", test.library.Name)
150+
if err := os.MkdirAll(snippetsDir, 0755); err != nil {
151+
t.Fatal(err)
152+
}
153+
154+
for path, content := range test.initialFiles {
155+
fullPath := filepath.Join(output, path)
156+
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
157+
t.Fatal(err)
158+
}
159+
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
160+
t.Fatal(err)
161+
}
162+
}
163+
err := Bump(test.library, output, test.version)
164+
if err != nil {
165+
t.Fatal(err)
166+
}
167+
for path, wantContent := range test.wantFiles {
168+
fullPath := filepath.Join(output, path)
169+
content, err := os.ReadFile(fullPath)
170+
if err != nil {
171+
t.Error(err)
172+
continue
173+
}
174+
got := string(content)
175+
if diff := cmp.Diff(wantContent, got); diff != "" {
176+
t.Errorf("mismatch (-want +got):\n%s", diff)
177+
}
178+
}
179+
})
180+
}
181+
}
182+
183+
func TestBump_Error(t *testing.T) {
184+
for _, test := range []struct {
185+
name string
186+
initialFiles map[string]string
187+
library *config.Library
188+
version string
189+
setup func(t *testing.T, dir string)
190+
wantErr error
191+
}{
192+
{
193+
name: "internal version file is read-only",
194+
initialFiles: map[string]string{
195+
"test-lib/internal/version.go": "package internal\n\nconst Version = \"0.1.0\"\n",
196+
},
197+
library: &config.Library{Name: "test-lib"},
198+
version: "0.2.0",
199+
setup: func(t *testing.T, dir string) {
200+
if err := os.Chmod(filepath.Join(dir, "test-lib", "internal", "version.go"), 0444); err != nil {
201+
t.Fatal(err)
202+
}
203+
},
204+
wantErr: os.ErrPermission,
205+
},
206+
{
207+
name: "snippet metadata is read-only",
208+
initialFiles: map[string]string{
209+
"internal/generated/snippets/test-lib/apiv1/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.1.0\"\n }\n}\n",
210+
},
211+
library: &config.Library{
212+
Name: "test-lib",
213+
APIs: []*config.API{
214+
{
215+
Path: "google/example/v1",
216+
},
217+
},
218+
Go: &config.GoModule{
219+
GoAPIs: []*config.GoAPI{
220+
{
221+
ImportPath: "test-lib/apiv1",
222+
Path: "google/example/v1",
223+
},
224+
},
225+
},
226+
},
227+
version: "0.2.0",
228+
setup: func(t *testing.T, dir string) {
229+
if err := os.Chmod(filepath.Join(dir, "internal", "generated", "snippets", "test-lib", "apiv1", "snippet_metadata_foo.json"), 0444); err != nil {
230+
t.Fatal(err)
231+
}
232+
},
233+
wantErr: os.ErrPermission,
234+
},
235+
{
236+
name: "no go api",
237+
initialFiles: map[string]string{
238+
"internal/generated/snippets/test-lib/snippet_metadata_foo.json": "{\n \"clientLibrary\": {\n \"version\": \"0.1.0\"\n }\n}\n",
239+
},
240+
library: &config.Library{
241+
Name: "test-lib",
242+
APIs: []*config.API{
243+
{
244+
Path: "google/example/v1",
245+
},
246+
},
247+
},
248+
version: "0.2.0",
249+
wantErr: errGoAPINotFound,
250+
},
251+
} {
252+
t.Run(test.name, func(t *testing.T) {
253+
output := t.TempDir()
254+
libraryDir := filepath.Join(output, test.library.Name)
255+
if err := os.MkdirAll(libraryDir, 0755); err != nil {
256+
t.Fatal(err)
257+
}
258+
snippetsDir := filepath.Join(output, "internal", "generated", "snippets", test.library.Name)
259+
if err := os.MkdirAll(snippetsDir, 0755); err != nil {
260+
t.Fatal(err)
261+
}
262+
263+
for path, content := range test.initialFiles {
264+
fullPath := filepath.Join(output, path)
265+
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
266+
t.Fatal(err)
267+
}
268+
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
269+
t.Fatal(err)
270+
}
271+
}
272+
273+
if test.setup != nil {
274+
test.setup(t, output)
275+
}
276+
err := Bump(test.library, output, test.version)
277+
if !errors.Is(err, test.wantErr) {
278+
t.Errorf("Bump() error = %v, wantErr %v", err, test.wantErr)
279+
}
280+
})
281+
}
282+
}

internal/librarian/golang/clean.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func Clean(library *config.Library) error {
6262
if err := clean(libraryDir, nestedModule, keepSet); err != nil {
6363
return err
6464
}
65-
snippetDir := filepath.Join(library.Output, "internal", "generated", "snippets", library.Name)
65+
snippetDir := snippetDirectory(library.Output, library.Name)
6666
if err := clean(snippetDir, nestedModule, nil); err != nil {
6767
return err
6868
}

internal/librarian/golang/generate.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ var (
4343
//go:embed template/_README.md.txt
4444
readmeTmpl string
4545
readmeTmplParsed = template.Must(template.New("readme").Parse(readmeTmpl))
46-
errGoAPINotFound = errors.New("go API not found")
4746
)
4847

4948
// GenerateLibraries generates all the given libraries in sequence.
@@ -140,7 +139,9 @@ func Format(ctx context.Context, library *config.Library) error {
140139
return err
141140
}
142141
args := []string{"-w", filepath.Join(outDir, library.Name)}
143-
snippetDir := filepath.Join(outDir, "internal", "generated", "snippets", library.Name)
142+
// TODO(https://github.com/googleapis/librarian/issues/4297), refactor this function
143+
// to use import path.
144+
snippetDir := snippetDirectory(outDir, library.Name)
144145
if _, err := os.Stat(snippetDir); err == nil {
145146
args = append(args, snippetDir)
146147
}

0 commit comments

Comments
 (0)