Skip to content

Commit 07f666d

Browse files
authored
feat(sidekick): subcommand to publish Rust crates (#2182)
1 parent 1a3e3bb commit 07f666d

File tree

11 files changed

+689
-0
lines changed

11 files changed

+689
-0
lines changed

internal/sidekick/internal/config/release.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type Release struct {
3434

3535
// IgnoredChanges defines globs that are ignored in change analysis.
3636
IgnoredChanges []string `toml:"ignored-changes,omitempty"`
37+
38+
// An alternative location for the `roots.pem` file. If empty it has no
39+
// effect.
40+
RootsPem string
3741
}
3842

3943
// Tool defines the configuration required to install helper tools.

internal/sidekick/internal/rust_release/bump_versions_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,32 @@ func setupForVersionBump(t *testing.T, wantTag string) {
188188
configNewGitRepository(t)
189189
}
190190

191+
func setupForPublish(t *testing.T, wantTag string) string {
192+
remoteDir := t.TempDir()
193+
continueInNewGitRepository(t, remoteDir)
194+
initRepositoryContents(t)
195+
if err := external.Run("git", "tag", wantTag); err != nil {
196+
t.Fatal(err)
197+
}
198+
name := path.Join("src", "storage", "src", "lib.rs")
199+
if err := os.WriteFile(name, []byte(newLibRsContents), 0644); err != nil {
200+
t.Fatal(err)
201+
}
202+
if err := external.Run("git", "commit", "-m", "feat: changed storage", "."); err != nil {
203+
t.Fatal(err)
204+
}
205+
return remoteDir
206+
}
207+
208+
func cloneRepository(t *testing.T, remoteDir string) {
209+
cloneDir := t.TempDir()
210+
t.Chdir(cloneDir)
211+
if err := external.Run("git", "clone", remoteDir, "."); err != nil {
212+
t.Fatal(err)
213+
}
214+
configNewGitRepository(t)
215+
}
216+
191217
func continueInNewGitRepository(t *testing.T, tmpDir string) {
192218
t.Helper()
193219
requireCommand(t, "git")

internal/sidekick/internal/rust_release/changes.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
package rustrelease
1616

1717
import (
18+
"bytes"
19+
"fmt"
1820
"os/exec"
1921
"slices"
2022
"strings"
@@ -23,6 +25,33 @@ import (
2325
"github.com/googleapis/librarian/internal/sidekick/internal/config"
2426
)
2527

28+
func matchesBranchPoint(config *config.Release) error {
29+
branch := fmt.Sprintf("%s/%s", config.Remote, config.Branch)
30+
delta := fmt.Sprintf("%s...HEAD", branch)
31+
cmd := exec.Command(gitExe(config), "diff", "--name-only", delta)
32+
cmd.Dir = "."
33+
output, err := cmd.CombinedOutput()
34+
if err != nil {
35+
return err
36+
}
37+
if len(output) != 0 {
38+
return fmt.Errorf("the local repository does not match is branch point from %s, change files:\n%s", branch, string(output))
39+
}
40+
return nil
41+
}
42+
43+
func isNewFile(config *config.Release, ref, name string) bool {
44+
delta := fmt.Sprintf("%s..HEAD", ref)
45+
cmd := exec.Command(gitExe(config), "diff", "--summary", delta, "--", name)
46+
cmd.Dir = "."
47+
output, err := cmd.CombinedOutput()
48+
if err != nil {
49+
return false
50+
}
51+
expected := fmt.Sprintf(" create mode 100644 %s", name)
52+
return bytes.HasPrefix(output, []byte(expected))
53+
}
54+
2655
func filesChangedSince(config *config.Release, ref string) ([]string, error) {
2756
cmd := exec.Command(gitExe(config), "diff", "--name-only", ref)
2857
cmd.Dir = "."

internal/sidekick/internal/rust_release/changes_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,97 @@ const (
2828
newLibRsContents = `pub fn hello() -> &'static str { "Hello World" }`
2929
)
3030

31+
func TestMatchesBranchPointSuccess(t *testing.T) {
32+
requireCommand(t, "git")
33+
config := &config.Release{
34+
Remote: "origin",
35+
Branch: "main",
36+
}
37+
remoteDir := setupForPublish(t, "v1.0.0")
38+
cloneRepository(t, remoteDir)
39+
if err := matchesBranchPoint(config); err != nil {
40+
t.Fatal(err)
41+
}
42+
}
43+
44+
func TestMatchesBranchDiffError(t *testing.T) {
45+
requireCommand(t, "git")
46+
config := &config.Release{
47+
Remote: "origin",
48+
Branch: "not-a-valid-branch",
49+
}
50+
remoteDir := setupForPublish(t, "v1.0.0")
51+
cloneRepository(t, remoteDir)
52+
if err := matchesBranchPoint(config); err == nil {
53+
t.Errorf("expected an error with an invalid branch")
54+
}
55+
}
56+
57+
func TestMatchesDirtyCloneError(t *testing.T) {
58+
requireCommand(t, "git")
59+
config := &config.Release{
60+
Remote: "origin",
61+
Branch: "not-a-valid-branch",
62+
}
63+
remoteDir := setupForPublish(t, "v1.0.0")
64+
cloneRepository(t, remoteDir)
65+
addCrate(t, path.Join("src", "pubsub"), "google-cloud-pubsub")
66+
if err := external.Run("git", "add", path.Join("src", "pubsub")); err != nil {
67+
t.Fatal(err)
68+
}
69+
if err := external.Run("git", "commit", "-m", "feat: created pubsub", "."); err != nil {
70+
t.Fatal(err)
71+
}
72+
73+
if err := matchesBranchPoint(config); err == nil {
74+
t.Errorf("expected an error with a dirty clone")
75+
}
76+
}
77+
78+
func TestIsNewFile(t *testing.T) {
79+
const wantTag = "new-file-success"
80+
release := config.Release{
81+
Remote: "origin",
82+
Branch: "main",
83+
Preinstalled: map[string]string{},
84+
}
85+
setupForVersionBump(t, wantTag)
86+
existingName := path.Join("src", "storage", "src", "lib.rs")
87+
if err := os.WriteFile(existingName, []byte(newLibRsContents), 0644); err != nil {
88+
t.Fatal(err)
89+
}
90+
newName := path.Join("src", "storage", "src", "new.rs")
91+
if err := os.WriteFile(newName, []byte(newLibRsContents), 0644); err != nil {
92+
t.Fatal(err)
93+
}
94+
if err := external.Run("git", "add", "."); err != nil {
95+
t.Fatal(err)
96+
}
97+
if err := external.Run("git", "commit", "-m", "feat: changed storage", "."); err != nil {
98+
t.Fatal(err)
99+
}
100+
if isNewFile(&release, wantTag, existingName) {
101+
t.Errorf("file is not new but reported as such: %s", existingName)
102+
}
103+
if !isNewFile(&release, wantTag, newName) {
104+
t.Errorf("file is new but not reported as such: %s", newName)
105+
}
106+
}
107+
108+
func TestIsNewFileDiffError(t *testing.T) {
109+
const wantTag = "new-file-success"
110+
release := config.Release{
111+
Remote: "origin",
112+
Branch: "main",
113+
Preinstalled: map[string]string{},
114+
}
115+
setupForVersionBump(t, wantTag)
116+
existingName := path.Join("src", "storage", "src", "lib.rs")
117+
if isNewFile(&release, "invalid-tag", existingName) {
118+
t.Errorf("diff errors should return false for isNewFile(): %s", existingName)
119+
}
120+
}
121+
31122
func TestFilesChangedSuccess(t *testing.T) {
32123
const wantTag = "release-2001-02-03"
33124

internal/sidekick/internal/rust_release/preflight.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package rustrelease
1616

1717
import (
1818
"fmt"
19+
"log/slog"
1920

2021
"github.com/googleapis/librarian/internal/sidekick/internal/config"
2122
"github.com/googleapis/librarian/internal/sidekick/internal/external"
@@ -37,6 +38,7 @@ func PreFlight(config *config.Release) error {
3738
return nil
3839
}
3940
for _, tool := range tools {
41+
slog.Info("installing cargo tool", "name", tool.Name, "version", tool.Version)
4042
spec := fmt.Sprintf("%s@%s", tool.Name, tool.Version)
4143
if err := external.Run(cargoExe(config), "install", "--locked", spec); err != nil {
4244
return err
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 rustrelease
16+
17+
import (
18+
"fmt"
19+
"log/slog"
20+
"maps"
21+
"os"
22+
"os/exec"
23+
"slices"
24+
"strings"
25+
26+
"github.com/google/go-cmp/cmp"
27+
"github.com/googleapis/librarian/internal/sidekick/internal/config"
28+
"github.com/googleapis/librarian/internal/sidekick/internal/external"
29+
)
30+
31+
// Publish finds all the crates that should be published, runs
32+
// `cargo semver-checks` and (optionally) publishes them.
33+
func Publish(config *config.Release, dryRun bool) error {
34+
if err := PreFlight(config); err != nil {
35+
return err
36+
}
37+
lastTag, err := getLastTag(config)
38+
if err != nil {
39+
return err
40+
}
41+
if err := matchesBranchPoint(config); err != nil {
42+
return err
43+
}
44+
files, err := filesChangedSince(config, lastTag)
45+
if err != nil {
46+
return err
47+
}
48+
manifests := map[string]string{}
49+
for _, manifest := range findCargoManifests(files) {
50+
names, err := publishedCrate(manifest)
51+
if err != nil {
52+
return err
53+
}
54+
for _, name := range names {
55+
manifests[name] = manifest
56+
}
57+
}
58+
slog.Info("computing publication plan with: cargo workspaces plan")
59+
cmd := exec.Command(cargoExe(config), "workspaces", "plan", "--skip-published")
60+
if config.RootsPem != "" {
61+
cmd.Env = append(os.Environ(), fmt.Sprintf("CARGO_HTTP_CAINFO=%s", config.RootsPem))
62+
}
63+
cmd.Dir = "."
64+
output, err := cmd.Output()
65+
if err != nil {
66+
return err
67+
}
68+
plannedCrates := strings.Split(string(output), "\n")
69+
plannedCrates = slices.DeleteFunc(plannedCrates, func(a string) bool { return a == "" })
70+
changedCrates := slices.Collect(maps.Keys(manifests))
71+
slices.Sort(plannedCrates)
72+
slices.Sort(changedCrates)
73+
if diff := cmp.Diff(changedCrates, plannedCrates); diff != "" && cargoExe(config) != "/bin/echo" {
74+
return fmt.Errorf("mismatched workspace plan vs. changed crates, probably missing some version bumps (-plan, +changed):\n%s", diff)
75+
}
76+
77+
for name, manifest := range manifests {
78+
if isNewFile(config, lastTag, manifest) {
79+
continue
80+
}
81+
slog.Info("runnning cargo semver-checks to detect breaking changes", "crate", name)
82+
if err := external.Run(cargoExe(config), "semver-checks", "--all-features", "-p", name); err != nil {
83+
return err
84+
}
85+
}
86+
slog.Info("publishing crates with: cargo workspaces publish --skip-published ...")
87+
args := []string{"workspaces", "publish", "--skip-published", "--publish-interval=60", "--no-git-commit", "--from-git", "skip"}
88+
if dryRun {
89+
args = append(args, "--dry-run")
90+
}
91+
cmd = exec.Command(cargoExe(config), args...)
92+
if config.RootsPem != "" {
93+
cmd.Env = append(os.Environ(), fmt.Sprintf("CARGO_HTTP_CAINFO=%s", config.RootsPem))
94+
}
95+
cmd.Dir = "."
96+
return cmd.Run()
97+
}

0 commit comments

Comments
 (0)