Skip to content

Commit cdc3e7c

Browse files
authored
Optimize release command (#2415)
The release command is very inefficient today. It currently scans all plugins looking in GHCR for every changed image since the last time it ran. It is possible however to optimize this to use the GHCR package API to check for plugins updated since the last release along with git changes since the last release to only check a handful of plugins instead of all ~1700 versions. Continue to support a PLUGINS env var as an escape hatch to check selected plugins.
1 parent 566788e commit cdc3e7c

6 files changed

Lines changed: 466 additions & 72 deletions

File tree

.github/workflows/release.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ on:
55
# At minute 30 past every 6th hour.
66
- cron: "30 */6 * * *"
77
workflow_dispatch:
8+
inputs:
9+
plugins:
10+
description: 'Force-include plugins (comma-separated, e.g. "connect-go,grpc/csharp:v1.68.1", or "all")'
11+
required: false
12+
default: ''
813

914
permissions:
1015
contents: write
@@ -23,6 +28,8 @@ jobs:
2328
steps:
2429
- name: Checkout repository code
2530
uses: actions/checkout@v6
31+
with:
32+
fetch-depth: 0 # full history and tags needed to diff against the prior release tag
2633
- name: Login to GitHub Container Registry
2734
if: github.repository == 'bufbuild/plugins'
2835
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
@@ -40,6 +47,7 @@ jobs:
4047
GITHUB_TOKEN: ${{ github.token }}
4148
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
4249
MINISIGN_PRIVATE_KEY_PASSWORD: ${{ secrets.MINISIGN_PRIVATE_KEY_PASSWORD }}
50+
PLUGINS: ${{ inputs.plugins }}
4351
run: |
4452
echo "${MINISIGN_PRIVATE_KEY}" > minisign.key
4553
go run ./internal/cmd/release --commit ${{ github.sha }} --minisign-private-key minisign.key .

internal/cmd/release/candidates.go

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package main
2+
3+
import (
4+
"cmp"
5+
"context"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
"slices"
10+
"strings"
11+
"time"
12+
13+
"github.com/google/go-github/v72/github"
14+
"golang.org/x/mod/semver"
15+
16+
"github.com/bufbuild/plugins/internal/git"
17+
"github.com/bufbuild/plugins/internal/plugin"
18+
"github.com/bufbuild/plugins/internal/release"
19+
)
20+
21+
// collectCandidates returns the set of plugin (name, version) pairs that may
22+
// have changed since latestRelease.
23+
//
24+
// A nil result means "treat every plugin as a candidate" and is returned when
25+
// there is no prior release (initial release).
26+
//
27+
// Candidates are the union of three sources:
28+
// - buf.plugin.yaml files changed since the prior release tag
29+
// - GHCR container packages with versions updated since the prior release
30+
// - plugins selected via the PLUGINS env var (escape hatch)
31+
func (c *command) collectCandidates(
32+
ctx context.Context,
33+
ghClient *release.Client,
34+
allPlugins []*plugin.Plugin,
35+
latestRelease *github.RepositoryRelease,
36+
) (map[pluginNameVersion]struct{}, error) {
37+
if latestRelease == nil {
38+
return nil, nil
39+
}
40+
candidates := make(map[pluginNameVersion]struct{})
41+
if tag := latestRelease.GetTagName(); tag != "" {
42+
added, err := c.addGitCandidates(ctx, tag, candidates)
43+
if err != nil {
44+
return nil, fmt.Errorf("git candidates: %w", err)
45+
}
46+
c.logger.InfoContext(ctx, "candidates from git diff",
47+
slog.String("tag", tag),
48+
slog.Any("plugins", added),
49+
)
50+
}
51+
// Reach back 30 minutes before the prior run started to catch images pushed
52+
// concurrent with it.
53+
since := latestRelease.GetCreatedAt().Add(-30 * time.Minute)
54+
added, err := c.addGHCRCandidates(ctx, ghClient, allPlugins, since, candidates)
55+
if err != nil {
56+
return nil, fmt.Errorf("ghcr candidates: %w", err)
57+
}
58+
c.logger.InfoContext(ctx, "candidates from ghcr",
59+
slog.Time("since", since),
60+
slog.Any("plugins", added),
61+
)
62+
added, err = c.addPluginsEnvCandidates(allPlugins, candidates)
63+
if err != nil {
64+
return nil, fmt.Errorf("plugins env candidates: %w", err)
65+
}
66+
c.logger.InfoContext(ctx, "candidates from PLUGINS env var",
67+
slog.Any("plugins", added),
68+
)
69+
return candidates, nil
70+
}
71+
72+
// sortedKeys returns a copy of keys sorted by name then version for stable
73+
// log output.
74+
func sortedKeys(keys []pluginNameVersion) []pluginNameVersion {
75+
out := slices.Clone(keys)
76+
slices.SortFunc(out, func(a, b pluginNameVersion) int {
77+
if c := cmp.Compare(a.name, b.name); c != 0 {
78+
return c
79+
}
80+
return cmp.Compare(a.version, b.version)
81+
})
82+
return out
83+
}
84+
85+
// addGitCandidates adds (name, version) pairs for every plugin whose
86+
// buf.plugin.yaml changed since ref.
87+
//
88+
// Only buf.plugin.yaml is scanned: changes to Dockerfile/patches/etc. rebuild
89+
// the image and are picked up by the GHCR pass, while changes to unreferenced
90+
// files (README, etc.) affect neither yaml_digest nor image_id and wouldn't
91+
// trigger a republish even if flagged.
92+
func (c *command) addGitCandidates(ctx context.Context, ref string, candidates map[pluginNameVersion]struct{}) ([]pluginNameVersion, error) {
93+
changedFiles, err := git.ChangedFilesFrom(ctx, ref)
94+
if err != nil {
95+
return nil, err
96+
}
97+
var added []pluginNameVersion
98+
for _, file := range changedFiles {
99+
key, ok := pluginKeyFromPath(file)
100+
if !ok {
101+
continue
102+
}
103+
if _, exists := candidates[key]; exists {
104+
continue
105+
}
106+
candidates[key] = struct{}{}
107+
added = append(added, key)
108+
}
109+
return sortedKeys(added), nil
110+
}
111+
112+
// pluginKeyFromPath parses "plugins/<owner>/<name>/<semver>/buf.plugin.yaml"
113+
// into {name: "<owner>/<name>", version: "<semver>"}. Any other path returns
114+
// (_, false).
115+
func pluginKeyFromPath(path string) (pluginNameVersion, bool) {
116+
rest, ok := strings.CutPrefix(strings.TrimSpace(path), "plugins/")
117+
if !ok {
118+
return pluginNameVersion{}, false
119+
}
120+
parts := strings.Split(rest, "/")
121+
if len(parts) != 4 || parts[3] != "buf.plugin.yaml" {
122+
return pluginNameVersion{}, false
123+
}
124+
if !semver.IsValid(parts[2]) {
125+
return pluginNameVersion{}, false
126+
}
127+
return pluginNameVersion{
128+
name: parts[0] + "/" + parts[1],
129+
version: parts[2],
130+
}, true
131+
}
132+
133+
// addGHCRCandidates adds (name, version) pairs for every container package
134+
// version whose image was updated after since.
135+
//
136+
// The list-packages endpoint returns every container package owned by the org
137+
// (a few dozen), and per-package version listings are only fetched for packages
138+
// that were touched after since.
139+
func (c *command) addGHCRCandidates(
140+
ctx context.Context,
141+
ghClient *release.Client,
142+
allPlugins []*plugin.Plugin,
143+
since time.Time,
144+
candidates map[pluginNameVersion]struct{},
145+
) ([]pluginNameVersion, error) {
146+
packageToPlugin := make(map[string]string, len(allPlugins))
147+
for _, p := range allPlugins {
148+
pkg := fmt.Sprintf("plugins-%s-%s", p.Identity.Owner(), p.Identity.Plugin())
149+
packageToPlugin[pkg] = p.Identity.Owner() + "/" + p.Identity.Plugin()
150+
}
151+
var added []pluginNameVersion
152+
opts := &github.PackageListOptions{
153+
PackageType: new("container"),
154+
ListOptions: github.ListOptions{PerPage: 100},
155+
}
156+
for {
157+
pkgs, resp, err := ghClient.GitHub.Organizations.ListPackages(ctx, string(release.GithubOwnerBufbuild), opts)
158+
if err != nil {
159+
return nil, fmt.Errorf("list packages: %w", err)
160+
}
161+
for _, pkg := range pkgs {
162+
if pkg.GetUpdatedAt().Before(since) {
163+
continue
164+
}
165+
pluginName, ok := packageToPlugin[pkg.GetName()]
166+
if !ok {
167+
continue
168+
}
169+
pkgAdded, err := c.addPackageVersionCandidates(ctx, ghClient, pkg.GetName(), pluginName, since, candidates)
170+
if err != nil {
171+
return nil, err
172+
}
173+
added = append(added, pkgAdded...)
174+
}
175+
if resp.NextPage == 0 {
176+
break
177+
}
178+
opts.Page = resp.NextPage
179+
}
180+
return sortedKeys(added), nil
181+
}
182+
183+
// addPackageVersionCandidates adds one entry per semver tag found on any
184+
// package version updated after since. It returns the keys newly added to the
185+
// candidate set by this pass.
186+
func (c *command) addPackageVersionCandidates(
187+
ctx context.Context,
188+
ghClient *release.Client,
189+
pkgName, pluginName string,
190+
since time.Time,
191+
candidates map[pluginNameVersion]struct{},
192+
) ([]pluginNameVersion, error) {
193+
var added []pluginNameVersion
194+
opts := &github.PackageListOptions{
195+
ListOptions: github.ListOptions{PerPage: 100},
196+
}
197+
for {
198+
versions, resp, err := ghClient.GitHub.Organizations.PackageGetAllVersions(
199+
ctx, string(release.GithubOwnerBufbuild), "container", pkgName, opts,
200+
)
201+
if err != nil {
202+
return nil, fmt.Errorf("list %q versions: %w", pkgName, err)
203+
}
204+
for _, v := range versions {
205+
if v.GetUpdatedAt().Before(since) {
206+
continue
207+
}
208+
meta, ok := v.GetMetadata()
209+
if !ok || meta.Container == nil {
210+
continue
211+
}
212+
for _, tag := range meta.Container.Tags {
213+
// Skip moving tags (latest, v1, v1.2); only canonical semver
214+
// corresponds to a plugin version directory.
215+
if semver.Canonical(tag) != tag {
216+
continue
217+
}
218+
key := pluginNameVersion{name: pluginName, version: tag}
219+
if _, exists := candidates[key]; exists {
220+
continue
221+
}
222+
candidates[key] = struct{}{}
223+
added = append(added, key)
224+
}
225+
}
226+
if resp.NextPage == 0 {
227+
break
228+
}
229+
opts.Page = resp.NextPage
230+
}
231+
return added, nil
232+
}
233+
234+
// addPluginsEnvCandidates applies the PLUGINS env var as an escape hatch. The
235+
// selected plugins are added to the candidate set; they still must have a
236+
// differing yaml or image digest to be republished.
237+
func (c *command) addPluginsEnvCandidates(allPlugins []*plugin.Plugin, candidates map[pluginNameVersion]struct{}) ([]pluginNameVersion, error) {
238+
pluginsEnv := os.Getenv("PLUGINS")
239+
if pluginsEnv == "" {
240+
return nil, nil
241+
}
242+
selected, err := plugin.FilterByPluginsEnv(allPlugins, pluginsEnv)
243+
if err != nil {
244+
return nil, err
245+
}
246+
var added []pluginNameVersion
247+
for _, p := range selected {
248+
key := pluginNameVersion{
249+
name: p.Identity.Owner() + "/" + p.Identity.Plugin(),
250+
version: p.PluginVersion,
251+
}
252+
if _, exists := candidates[key]; exists {
253+
continue
254+
}
255+
candidates[key] = struct{}{}
256+
added = append(added, key)
257+
}
258+
return sortedKeys(added), nil
259+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestPluginKeyFromPath(t *testing.T) {
10+
t.Parallel()
11+
tests := []struct {
12+
name string
13+
path string
14+
key pluginNameVersion
15+
ok bool
16+
}{
17+
{
18+
name: "yaml",
19+
path: "plugins/bufbuild/connect-go/v1.0.0/buf.plugin.yaml",
20+
key: pluginNameVersion{name: "bufbuild/connect-go", version: "v1.0.0"},
21+
ok: true,
22+
},
23+
{
24+
name: "dockerfile",
25+
path: "plugins/grpc-ecosystem/grpc-gateway/v2.15.0/Dockerfile",
26+
ok: false,
27+
},
28+
{
29+
name: "invalid_version",
30+
path: "plugins/bufbuild/connect-go/foo/buf.plugin.yaml",
31+
ok: false,
32+
},
33+
{
34+
name: "outside_plugins_dir",
35+
path: "README.md",
36+
ok: false,
37+
},
38+
{
39+
name: "empty",
40+
path: "",
41+
ok: false,
42+
},
43+
}
44+
for _, tc := range tests {
45+
t.Run(tc.name, func(t *testing.T) {
46+
t.Parallel()
47+
got, ok := pluginKeyFromPath(tc.path)
48+
assert.Equal(t, tc.ok, ok)
49+
if tc.ok {
50+
assert.Equal(t, tc.key, got)
51+
}
52+
})
53+
}
54+
}

0 commit comments

Comments
 (0)