Skip to content

Commit be56755

Browse files
authored
feat(java): release stage to update pom.xml files (#2772)
The 1st part of the release stage command for Java is updating pom.xml files. For #2516
1 parent f15e612 commit be56755

File tree

6 files changed

+368
-3
lines changed

6 files changed

+368
-3
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 pom
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
"regexp"
22+
)
23+
24+
var (
25+
versionRegex = regexp.MustCompile(`(<version>)([^<]+)(</version>\s*<!-- \{x-version-update:([^:]+):current\} -->)`)
26+
)
27+
28+
// UpdateVersions updates the versions of all pom.xml files in a given directory.
29+
// It appends the "-SNAPSHOT" suffix to the version given the version parameter.
30+
// If the directory is not present, this function creates it.
31+
func UpdateVersions(repoDir, sourcePath, outputDir, libraryID, version string) error {
32+
pomFiles, err := findPomFiles(sourcePath)
33+
if err != nil {
34+
return fmt.Errorf("failed to find pom files: %w", err)
35+
}
36+
for _, pomFile := range pomFiles {
37+
relPath, err := filepath.Rel(repoDir, pomFile)
38+
if err != nil {
39+
return fmt.Errorf("failed to get relative path for %s: %w", pomFile, err)
40+
}
41+
outputPomFile := filepath.Join(outputDir, relPath)
42+
if err := os.MkdirAll(filepath.Dir(outputPomFile), 0755); err != nil {
43+
return fmt.Errorf("failed to create output directory for %s: %w", outputPomFile, err)
44+
}
45+
if err := updateVersion(pomFile, outputPomFile, libraryID, version); err != nil {
46+
return fmt.Errorf("failed to update version in %s: %w", pomFile, err)
47+
}
48+
}
49+
return nil
50+
}
51+
52+
// updateVersion updates the version in a single pom.xml file.
53+
// It appends the "-SNAPSHOT" suffix to the the version parameter.
54+
// The directory for outputPath must already exist.
55+
func updateVersion(inputPath, outputPath, libraryID, version string) error {
56+
content, err := os.ReadFile(inputPath)
57+
if err != nil {
58+
return fmt.Errorf("failed to read file: %w", err)
59+
}
60+
61+
newContent := versionRegex.ReplaceAllStringFunc(string(content), func(s string) string {
62+
matches := versionRegex.FindStringSubmatch(s)
63+
if len(matches) > 4 && matches[4] == libraryID {
64+
// matches[1] is "<version>"
65+
// matches[2] is the old version
66+
// matches[3] is " <!-- {x-version-update:libraryID:current} --> </version>"
67+
// matches[4] is libraryID
68+
return fmt.Sprintf("%s%s-SNAPSHOT%s", matches[1], version, matches[3])
69+
}
70+
return s
71+
})
72+
73+
if err := os.WriteFile(outputPath, []byte(newContent), 0644); err != nil {
74+
return fmt.Errorf("failed to write file: %w", err)
75+
}
76+
return nil
77+
}
78+
79+
func findPomFiles(path string) ([]string, error) {
80+
var pomFiles []string
81+
// Return empty if there's no matching directory.
82+
if _, err := os.Stat(path); os.IsNotExist(err) {
83+
return []string{}, nil
84+
}
85+
86+
err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
87+
if err != nil {
88+
return err
89+
}
90+
if !info.IsDir() && info.Name() == "pom.xml" {
91+
pomFiles = append(pomFiles, path)
92+
}
93+
return nil
94+
})
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to walk path: %w", err)
97+
}
98+
return pomFiles, nil
99+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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 pom
16+
17+
import (
18+
"os"
19+
"path/filepath"
20+
"testing"
21+
)
22+
23+
func TestUpdateVersion(t *testing.T) {
24+
tests := []struct {
25+
name string
26+
initial string
27+
libraryID string
28+
version string
29+
expected string
30+
expectError bool
31+
}{
32+
{
33+
name: "happy path",
34+
initial: `<project>
35+
<version>1.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-java:current} -->
36+
</project>`,
37+
libraryID: "google-cloud-java",
38+
version: "2.0.0",
39+
expected: `<project>
40+
<version>2.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-java:current} -->
41+
</project>`,
42+
},
43+
{
44+
name: "no match",
45+
initial: `<project>
46+
<version>1.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-java:current} -->
47+
</project>`,
48+
libraryID: "wrong-library-id",
49+
version: "2.0.0",
50+
expected: `<project>
51+
<version>1.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-java:current} -->
52+
</project>`,
53+
},
54+
{
55+
name: "multiple versions",
56+
initial: `<project>
57+
<version>1.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-java:current} -->
58+
<dependency>
59+
<groupId>com.google.cloud</groupId>
60+
<artifactId>google-cloud-secretmanager</artifactId>
61+
<version>1.2.3-SNAPSHOT</version><!-- {x-version-update:google-cloud-secretmanager:current} -->
62+
</dependency>
63+
</project>`,
64+
libraryID: "google-cloud-secretmanager",
65+
version: "2.0.0",
66+
expected: `<project>
67+
<version>1.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-java:current} -->
68+
<dependency>
69+
<groupId>com.google.cloud</groupId>
70+
<artifactId>google-cloud-secretmanager</artifactId>
71+
<version>2.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-secretmanager:current} -->
72+
</dependency>
73+
</project>`,
74+
},
75+
{
76+
name: "no comment",
77+
initial: `<project>
78+
<version>1.0.0-SNAPSHOT</version>
79+
</project>`,
80+
libraryID: "google-cloud-java",
81+
version: "2.0.0",
82+
expected: `<project>
83+
<version>1.0.0-SNAPSHOT</version>
84+
</project>`,
85+
},
86+
}
87+
88+
for _, test := range tests {
89+
t.Run(test.name, func(t *testing.T) {
90+
tmpDir := t.TempDir()
91+
pomPath := filepath.Join(tmpDir, "pom.xml")
92+
outPath := filepath.Join(tmpDir, "out", "pom.xml")
93+
if err := os.WriteFile(pomPath, []byte(test.initial), 0644); err != nil {
94+
t.Fatalf("failed to write initial pom.xml: %v", err)
95+
}
96+
97+
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
98+
t.Fatalf("failed to create output directory: %v", err)
99+
}
100+
err := updateVersion(pomPath, outPath, test.libraryID, test.version)
101+
102+
if test.expectError {
103+
if err == nil {
104+
t.Errorf("expected error, got nil")
105+
}
106+
} else {
107+
if err != nil {
108+
t.Errorf("unexpected error: %v", err)
109+
}
110+
content, readErr := os.ReadFile(outPath)
111+
if readErr != nil {
112+
t.Fatalf("failed to read pom.xml: %v", readErr)
113+
}
114+
if string(content) != test.expected {
115+
t.Errorf("expected:\n%s\ngot:\n%s", test.expected, string(content))
116+
}
117+
}
118+
})
119+
}
120+
}

internal/container/java/release/release.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// you may not use this file except in compliance with the License.
55
// You may obtain a copy of the License at
66
//
7-
// http://www.apache.org/licenses/LICENSE-2.0
7+
// https://www.apache.org/licenses/LICENSE-2.0
88
//
99
// Unless required by applicable law or agreed to in writing, software
1010
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -18,14 +18,28 @@ package release
1818
import (
1919
"context"
2020
"log/slog"
21+
"path/filepath"
2122

2223
"github.com/googleapis/librarian/internal/container/java/languagecontainer/release"
2324
"github.com/googleapis/librarian/internal/container/java/message"
25+
"github.com/googleapis/librarian/internal/container/java/pom"
2426
)
2527

2628
// Stage executes the release stage command.
2729
func Stage(ctx context.Context, cfg *release.Config) (*message.ReleaseStageResponse, error) {
2830
slog.Info("release-stage: invoked", "config", cfg)
29-
// TODO(suztomo): implement release-stage.
30-
return &message.ReleaseStageResponse{}, nil
31+
response := &message.ReleaseStageResponse{}
32+
for _, lib := range cfg.Request.Libraries {
33+
for _, path := range lib.SourcePaths {
34+
slog.Info("release-stage: processing library", "libraryID", lib.ID, "version", lib.Version, "sourcePath", path)
35+
if err := pom.UpdateVersions(
36+
cfg.Context.RepoDir,
37+
filepath.Join(cfg.Context.RepoDir, path),
38+
cfg.Context.OutputDir, lib.ID, lib.Version); err != nil {
39+
response.Error = err.Error()
40+
return response, err
41+
}
42+
}
43+
}
44+
return response, nil
3145
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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 release
16+
17+
import (
18+
"context"
19+
"os"
20+
"path/filepath"
21+
"strings"
22+
"testing"
23+
24+
"github.com/googleapis/librarian/internal/container/java/languagecontainer/release"
25+
"github.com/googleapis/librarian/internal/container/java/message"
26+
)
27+
28+
func TestStage(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
libraryID string
32+
SourcePaths []string
33+
version string
34+
expected string
35+
}{
36+
{
37+
name: "happy path",
38+
libraryID: "google-cloud-foo",
39+
SourcePaths: []string{
40+
"java-foo",
41+
},
42+
version: "2.0.0",
43+
expected: "<version>2.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-foo:current} -->",
44+
},
45+
{
46+
name: "Source Paths not matching the folder",
47+
libraryID: "google-cloud-java",
48+
SourcePaths: []string{
49+
"java-nonexistent",
50+
},
51+
version: "2.0.0",
52+
// Do not expect the files updated since the source path does not exist.
53+
expected: "",
54+
},
55+
}
56+
57+
for _, test := range tests {
58+
test := test
59+
t.Run(test.name, func(t *testing.T) {
60+
// This testdata is the dummy repository root.
61+
inputPath := filepath.Join("testdata")
62+
63+
tmpDir := t.TempDir()
64+
outputDir := filepath.Join(tmpDir, "output")
65+
if err := os.MkdirAll(outputDir, 0755); err != nil {
66+
t.Fatalf("failed to create output directory: %v", err)
67+
}
68+
cfg := &release.Config{
69+
Context: &release.Context{
70+
RepoDir: inputPath,
71+
OutputDir: outputDir,
72+
},
73+
Request: &message.ReleaseStageRequest{
74+
Libraries: []*message.Library{
75+
{
76+
ID: test.libraryID,
77+
Version: test.version,
78+
SourcePaths: test.SourcePaths,
79+
},
80+
},
81+
},
82+
}
83+
84+
response, err := Stage(context.Background(), cfg)
85+
if err != nil {
86+
t.Fatalf("Stage() got unexpected error: %v", err)
87+
}
88+
89+
if response.Error != "" {
90+
t.Errorf("expected success, got error: %s", response.Error)
91+
}
92+
if test.expected != "" {
93+
// The file paths are relative to the repoDir.
94+
for _, file := range []string{"java-foo/pom.xml", "java-foo/google-cloud-foo/pom.xml"} {
95+
content, err := os.ReadFile(filepath.Join(outputDir, file))
96+
if err != nil {
97+
t.Fatalf("failed to read output file: %v", err)
98+
}
99+
100+
hasExpectedVersionLineWithAnnotation := strings.Contains(string(content), test.expected)
101+
if !hasExpectedVersionLineWithAnnotation {
102+
t.Errorf("expected file to contain annotation %q and comment, got %q", test.expected, string(content))
103+
}
104+
}
105+
} else {
106+
// Expect no files in the output directory because this operation
107+
// does not change any files in the repodir.
108+
entries, err := os.ReadDir(outputDir)
109+
if err != nil {
110+
t.Fatalf("failed to read output directory: %v", err)
111+
}
112+
if len(entries) != 0 {
113+
t.Errorf("expected no files in output directory, got %d files", len(entries))
114+
}
115+
}
116+
})
117+
}
118+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
2+
<modelVersion>4.0.0</modelVersion>
3+
4+
<artifactId>google-cloud-foo</artifactId>
5+
<version>1.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-foo:current} -->
6+
<packaging>jar</packaging>
7+
</project>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
2+
<modelVersion>4.0.0</modelVersion>
3+
4+
<artifactId>google-cloud-foo-parent</artifactId>
5+
<version>1.0.0-SNAPSHOT</version><!-- {x-version-update:google-cloud-foo:current} -->
6+
<packaging>pom</packaging>
7+
</project>

0 commit comments

Comments
 (0)