Skip to content

Commit e0eeddb

Browse files
authored
feat: allow configuring tag_format in config.yaml (#2236)
Fixes #2177 Moves tag formatting logic into `config` package as this logic is shared amongst other packages and only depends on types from `config`. Adds the ability to load the librarian config file directly from GitHub
1 parent 65fa7ed commit e0eeddb

12 files changed

+255
-93
lines changed

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const (
5555
ReleaseInitResponse = "release-init-response.json"
5656
// LibrarianStateFile is the name of the pipeline state file.
5757
LibrarianStateFile = "state.yaml"
58+
// LibrarianConfigFile is the name of the language-repository config file.
59+
LibrarianConfigFile = "config.yaml"
5860
// LibrarianGithubToken is the name of the env var used to store the github token.
5961
LibrarianGithubToken = "LIBRARIAN_GITHUB_TOKEN"
6062
)

internal/config/librarian_config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@ const (
2828
type LibrarianConfig struct {
2929
GlobalFilesAllowlist []*GlobalFile `yaml:"global_files_allowlist"`
3030
Libraries []*LibraryConfig `yaml:"libraries"`
31+
TagFormat string `yaml:"tag_format"`
3132
}
3233

3334
// LibraryConfig defines configuration for a single library, identified by its ID.
3435
type LibraryConfig struct {
36+
GenerateBlocked bool `yaml:"generate_blocked"`
3537
LibraryID string `yaml:"id"`
3638
NextVersion string `yaml:"next_version"`
37-
GenerateBlocked bool `yaml:"generate_blocked"`
3839
ReleaseBlocked bool `yaml:"release_blocked"`
40+
TagFormat string `yaml:"tag_format"`
3941
}
4042

4143
// GlobalFile defines the global files in language repositories.

internal/config/tag_format.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2025 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 config
16+
17+
import (
18+
"log/slog"
19+
"strings"
20+
)
21+
22+
const defaultTagFormat = "{id}-{version}"
23+
24+
// DetermineTagFormat finds the tag_format config given a library ID.
25+
func DetermineTagFormat(libraryID string, libraryState *LibraryState, librarianConfig *LibrarianConfig) string {
26+
// Order of preference:
27+
// 1. per-library from config.yaml
28+
// 2. top-level from config.yaml
29+
// 3. per-library from state.yaml (deprecated)
30+
if librarianConfig != nil {
31+
// prefer per-library config
32+
libraryConfig := librarianConfig.LibraryConfigFor(libraryID)
33+
if libraryConfig != nil && libraryConfig.TagFormat != "" {
34+
return libraryConfig.TagFormat
35+
}
36+
// top-level from config
37+
if librarianConfig.TagFormat != "" {
38+
return librarianConfig.TagFormat
39+
}
40+
}
41+
42+
if libraryState != nil {
43+
if libraryState.TagFormat != "" {
44+
return libraryState.TagFormat
45+
}
46+
}
47+
slog.Warn("library did not configure tag_format, using default", "libraryID", libraryID, "format", defaultTagFormat)
48+
return defaultTagFormat
49+
}
50+
51+
// FormatTag returns the git tag for a given library version.
52+
func FormatTag(tagFormat string, libraryID string, version string) string {
53+
if tagFormat == "" {
54+
slog.Warn("not tag format specified, using default", "format", defaultTagFormat)
55+
tagFormat = defaultTagFormat
56+
}
57+
r := strings.NewReplacer("{id}", libraryID, "{version}", version)
58+
return r.Replace(tagFormat)
59+
}

internal/config/tag_format_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2025 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 config
16+
17+
import (
18+
"testing"
19+
20+
"github.com/google/go-cmp/cmp"
21+
)
22+
23+
func TestDetermineTagFormat(t *testing.T) {
24+
for _, test := range []struct {
25+
name string
26+
libraryState *LibraryState
27+
librarianConfig *LibrarianConfig
28+
want string
29+
wantErrMsg string
30+
}{
31+
{
32+
name: "uses_default",
33+
libraryState: &LibraryState{
34+
ID: "example-library",
35+
},
36+
librarianConfig: &LibrarianConfig{},
37+
want: defaultTagFormat,
38+
},
39+
{
40+
name: "prefers_per-library_from_config",
41+
libraryState: &LibraryState{
42+
ID: "example-library",
43+
TagFormat: "per-library-tag-format-from-state",
44+
},
45+
librarianConfig: &LibrarianConfig{
46+
TagFormat: "from-config",
47+
Libraries: []*LibraryConfig{
48+
{
49+
LibraryID: "example-library",
50+
TagFormat: "per-library-tag-format-from-config",
51+
},
52+
},
53+
},
54+
want: "per-library-tag-format-from-config",
55+
},
56+
{
57+
name: "prefers_from_config",
58+
libraryState: &LibraryState{
59+
ID: "example-library",
60+
TagFormat: "per-library-tag-format-from-state",
61+
},
62+
librarianConfig: &LibrarianConfig{
63+
TagFormat: "from-config",
64+
Libraries: []*LibraryConfig{
65+
{
66+
LibraryID: "example-library",
67+
},
68+
},
69+
},
70+
want: "from-config",
71+
},
72+
{
73+
name: "falls_back_to_per-library_from_state",
74+
libraryState: &LibraryState{
75+
ID: "example-library",
76+
TagFormat: "per-library-tag-format-from-state",
77+
},
78+
librarianConfig: &LibrarianConfig{},
79+
want: "per-library-tag-format-from-state",
80+
},
81+
} {
82+
t.Run(test.name, func(t *testing.T) {
83+
got := DetermineTagFormat("example-library", test.libraryState, test.librarianConfig)
84+
85+
if diff := cmp.Diff(test.want, got); diff != "" {
86+
t.Errorf("determineTagFormat() mismatch (-want +got):\n%s", diff)
87+
}
88+
})
89+
}
90+
}
91+
92+
func TestFormatTag(t *testing.T) {
93+
t.Parallel()
94+
for _, test := range []struct {
95+
name string
96+
library *LibraryState
97+
want string
98+
}{
99+
{
100+
name: "default_format",
101+
library: &LibraryState{
102+
ID: "google.cloud.foo.v1",
103+
Version: "1.2.3",
104+
},
105+
want: "google.cloud.foo.v1-1.2.3",
106+
},
107+
{
108+
name: "custom_format",
109+
library: &LibraryState{
110+
ID: "google.cloud.foo.v1",
111+
Version: "1.2.3",
112+
TagFormat: "v{version}-{id}",
113+
},
114+
want: "v1.2.3-google.cloud.foo.v1",
115+
},
116+
{
117+
name: "custom_format_version_only",
118+
library: &LibraryState{
119+
ID: "google.cloud.foo.v1",
120+
Version: "1.2.3",
121+
TagFormat: "v{version}",
122+
},
123+
want: "v1.2.3",
124+
},
125+
} {
126+
t.Run(test.name, func(t *testing.T) {
127+
got := FormatTag(test.library.TagFormat, test.library.ID, test.library.Version)
128+
if got != test.want {
129+
t.Errorf("FormatTag() = %q, want %q", got, test.want)
130+
}
131+
})
132+
}
133+
}

internal/librarian/commit_version_analyzer.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@ import (
2626
"github.com/googleapis/librarian/internal/semver"
2727
)
2828

29-
const defaultTagFormat = "{id}-{version}"
30-
3129
// getConventionalCommitsSinceLastRelease returns all conventional commits for the given library since the
3230
// version specified in the state file. The repo should be the language repo.
33-
func getConventionalCommitsSinceLastRelease(repo gitrepo.Repository, library *config.LibraryState) ([]*conventionalcommits.ConventionalCommit, error) {
34-
tag := formatTag(library.TagFormat, library.ID, library.Version)
31+
func getConventionalCommitsSinceLastRelease(repo gitrepo.Repository, library *config.LibraryState, tag string) ([]*conventionalcommits.ConventionalCommit, error) {
3532
commits, err := repo.GetCommitsForPathsSinceTag(library.SourceRoots, tag)
33+
3634
if err != nil {
3735
return nil, fmt.Errorf("failed to get commits for library %s: %w", library.ID, err)
3836
}
@@ -137,15 +135,6 @@ func isUnderAnyPath(file string, paths []string) bool {
137135
return false
138136
}
139137

140-
// formatTag returns the git tag for a given library version.
141-
func formatTag(tagFormat string, libraryID string, version string) string {
142-
if tagFormat == "" {
143-
tagFormat = defaultTagFormat
144-
}
145-
r := strings.NewReplacer("{id}", libraryID, "{version}", version)
146-
return r.Replace(tagFormat)
147-
}
148-
149138
// NextVersion calculates the next semantic version based on a slice of conventional commits.
150139
func NextVersion(commits []*conventionalcommits.ConventionalCommit, currentVersion string) (string, error) {
151140
highestChange := getHighestChange(commits)

internal/librarian/commit_version_analyzer_test.go

Lines changed: 8 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -179,49 +179,6 @@ func TestShouldIncludeForGeneration(t *testing.T) {
179179
}
180180
}
181181

182-
func TestFormatTag(t *testing.T) {
183-
t.Parallel()
184-
for _, test := range []struct {
185-
name string
186-
library *config.LibraryState
187-
want string
188-
}{
189-
{
190-
name: "default format",
191-
library: &config.LibraryState{
192-
ID: "google.cloud.foo.v1",
193-
Version: "1.2.3",
194-
},
195-
want: "google.cloud.foo.v1-1.2.3",
196-
},
197-
{
198-
name: "custom format",
199-
library: &config.LibraryState{
200-
ID: "google.cloud.foo.v1",
201-
Version: "1.2.3",
202-
TagFormat: "v{version}-{id}",
203-
},
204-
want: "v1.2.3-google.cloud.foo.v1",
205-
},
206-
{
207-
name: "custom format -- version only",
208-
library: &config.LibraryState{
209-
ID: "google.cloud.foo.v1",
210-
Version: "1.2.3",
211-
TagFormat: "v{version}",
212-
},
213-
want: "v1.2.3",
214-
},
215-
} {
216-
t.Run(test.name, func(t *testing.T) {
217-
got := formatTag(test.library.TagFormat, test.library.ID, test.library.Version)
218-
if got != test.want {
219-
t.Errorf("formatTag() = %q, want %q", got, test.want)
220-
}
221-
})
222-
}
223-
}
224-
225182
func TestGetConventionalCommitsSinceLastRelease(t *testing.T) {
226183
t.Parallel()
227184
pathAndMessages := []pathAndMessage{
@@ -345,21 +302,21 @@ func TestGetConventionalCommitsSinceLastRelease(t *testing.T) {
345302
},
346303
} {
347304
t.Run(test.name, func(t *testing.T) {
348-
got, err := getConventionalCommitsSinceLastRelease(test.repo, test.library)
305+
got, err := getConventionalCommitsSinceLastRelease(test.repo, test.library, "")
349306
if test.wantErr {
350307
if err == nil {
351-
t.Fatal("GetConventionalCommitsSinceLastRelease() should have failed")
308+
t.Fatal("getConventionalCommitsSinceLastRelease() should have failed")
352309
}
353310
if !strings.Contains(err.Error(), test.wantErrPhrase) {
354-
t.Errorf("GetConventionalCommitsSinceLastRelease() returned error %q, want to contain %q", err.Error(), test.wantErrPhrase)
311+
t.Errorf("getConventionalCommitsSinceLastRelease() returned error %q, want to contain %q", err.Error(), test.wantErrPhrase)
355312
}
356313
return
357314
}
358315
if err != nil {
359-
t.Fatalf("GetConventionalCommitsSinceLastRelease() failed: %v", err)
316+
t.Fatalf("getConventionalCommitsSinceLastRelease() failed: %v", err)
360317
}
361318
if diff := cmp.Diff(test.want, got, cmpopts.IgnoreFields(conventionalcommits.ConventionalCommit{}, "SHA", "CommitHash", "Body", "IsBreaking", "When")); diff != "" {
362-
t.Errorf("GetConventionalCommitsSinceLastRelease() mismatch (-want +got):\n%s", diff)
319+
t.Errorf("getConventionalCommitsSinceLastRelease() mismatch (-want +got):\n%s", diff)
363320
}
364321
})
365322
}
@@ -453,15 +410,15 @@ func TestGetConventionalCommitsSinceLastGeneration(t *testing.T) {
453410
t.Fatal("getConventionalCommitsSinceLastGeneration() should have failed")
454411
}
455412
if !strings.Contains(err.Error(), test.wantErrPhrase) {
456-
t.Errorf("GetConventionalCommitsSinceLastRelease() returned error %q, want to contain %q", err.Error(), test.wantErrPhrase)
413+
t.Errorf("getConventionalCommitsSinceLastRelease() returned error %q, want to contain %q", err.Error(), test.wantErrPhrase)
457414
}
458415
return
459416
}
460417
if err != nil {
461-
t.Fatalf("GetConventionalCommitsSinceLastRelease() failed: %v", err)
418+
t.Fatalf("getConventionalCommitsSinceLastRelease() failed: %v", err)
462419
}
463420
if diff := cmp.Diff(test.want, got, cmpopts.IgnoreFields(conventionalcommits.ConventionalCommit{}, "SHA", "CommitHash", "Body", "IsBreaking", "When")); diff != "" {
464-
t.Errorf("GetConventionalCommitsSinceLastRelease() mismatch (-want +got):\n%s", diff)
421+
t.Errorf("getConventionalCommitsSinceLastRelease() mismatch (-want +got):\n%s", diff)
465422
}
466423
})
467424
}

internal/librarian/release_init.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,9 @@ func (r *initRunner) runInitCommand(ctx context.Context, outputDir string) error
196196
// processLibrary wrapper to process the library for release. Helps retrieve latest commits
197197
// since the last release and passing the changes to updateLibrary.
198198
func (r *initRunner) processLibrary(library *config.LibraryState) error {
199-
commits, err := getConventionalCommitsSinceLastRelease(r.repo, library)
199+
tagFormat := config.DetermineTagFormat(library.ID, library, r.librarianConfig)
200+
tagName := config.FormatTag(tagFormat, library.ID, library.Version)
201+
commits, err := getConventionalCommitsSinceLastRelease(r.repo, library, tagName)
200202
if err != nil {
201203
return fmt.Errorf("failed to fetch conventional commits for library, %s: %w", library.ID, err)
202204
}

internal/librarian/release_init_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1039,8 +1039,14 @@ func TestProcessLibrary(t *testing.T) {
10391039
wantErrMsg: "failed to fetch conventional commits for library",
10401040
},
10411041
} {
1042+
state := &config.LibrarianState{
1043+
Libraries: []*config.LibraryState{
1044+
test.libraryState,
1045+
},
1046+
}
10421047
r := &initRunner{
1043-
repo: test.repo,
1048+
repo: test.repo,
1049+
state: state,
10441050
}
10451051
err := r.processLibrary(test.libraryState)
10461052
if test.wantErr {

internal/librarian/release_notes.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,9 @@ func formatReleaseNotes(state *config.LibrarianState, ghRepo *github.Repository)
269269
func formatLibraryReleaseNotes(library *config.LibraryState, ghRepo *github.Repository) *releaseNoteSection {
270270
// The version should already be updated to the next version.
271271
newVersion := library.Version
272-
newTag := formatTag(library.TagFormat, library.ID, newVersion)
273-
previousTag := formatTag(library.TagFormat, library.ID, library.PreviousVersion)
272+
tagFormat := config.DetermineTagFormat(library.ID, library, nil)
273+
newTag := config.FormatTag(tagFormat, library.ID, newVersion)
274+
previousTag := config.FormatTag(tagFormat, library.ID, library.PreviousVersion)
274275

275276
commitsByType := make(map[string][]*conventionalcommits.ConventionalCommit)
276277
for _, commit := range library.Changes {

0 commit comments

Comments
 (0)