|
| 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 | +} |
0 commit comments