From eb92049ffff9d0c44b06f890cd7554e0859d10c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Martins?= Date: Wed, 29 Jul 2026 10:51:37 +0200 Subject: [PATCH] cmd/release: enforce backport ordering across stable branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fix that lands in an older stable branch while still missing from a newer one is an upgrade regression: upgrades always move from an older to a newer minor, so the user loses the fix on the way up. Today nothing catches this. A backporter can open the vX.15 backport PR, a bugfix then merges carrying needs-backport for both X.15 and X.14, and the subsequently-opened X.14 backport picks it up. The fix ships in X.14 but not X.15, and the only signal is the implicit "please stop merging backport PRs" message before a release. Add a check to the pre-check step that enforces the invariant. A violation is a merged upstream PR carrying backport-done/ together with needs-backport/ or backport-pending/, where B is newer than A. The check runs relative to the branch being released and covers both directions: the released branch lagging behind an older branch, and the released branch being ahead of a newer one. That way releasing any branch surfaces both sides of the invariant rather than only the ones behind it. Violations hard-block the release, matching the existing release-blocker check rather than the softer opened-backports prompt, since shipping a known upgrade regression is not something to confirm past by pressing Y. --force still overrides it, and the offending PRs are printed with their search URLs so they can be triaged. Only the --maintained-minors (default 3) most-recent branches are compared, plus the released one. Cilium maintains a fixed window of minors, and EOL branches can still carry stale needs-backport labels that would otherwise be reported as violations nobody intends to fix. Note this detects a lagging branch only when it carries an explicit needs-backport or backport-pending label. A fix never labelled for the newer branch at all is invisible to a label-pair query and would need an absence-of-label enumeration to catch. AIL:3 Signed-off-by: André Martins --- cmd/release/github.go | 34 +++++ cmd/release/issues.go | 255 ++++++++++++++++++++++++++++++++++++ cmd/release/issues_test.go | 258 +++++++++++++++++++++++++++++++++++++ cmd/release/start.go | 2 + pkg/github/labels.go | 20 ++- pkg/github/labels_test.go | 57 ++++++++ 6 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 cmd/release/issues_test.go diff --git a/cmd/release/github.go b/cmd/release/github.go index 64793f8b..dae0fe94 100644 --- a/cmd/release/github.go +++ b/cmd/release/github.go @@ -85,6 +85,40 @@ func (ghClient *GHClient) getRemoteBranch(ctx context.Context, owner, repo, targ } } +// getStableBranches returns all active, protected stable branches (those whose +// name is a semver major.minor version, e.g. "v1.15") for the given owner and +// repo. The default development branch (e.g. "main") is not a valid semver +// version and is therefore naturally excluded. +func (ghClient *GHClient) getStableBranches(ctx context.Context, owner, repo string) ([]string, error) { + page := 0 + var stableBranches []string + for { + branches, resp, err := ghClient.ghClient.Repositories.ListBranches(ctx, owner, repo, &gh.BranchListOptions{ + Protected: func() *bool { a := true; return &a }(), + ListOptions: gh.ListOptions{ + Page: page, + }, + }) + if err != nil { + return nil, err + } + for _, br := range branches { + name := br.GetName() + // Only keep branches that are a bare major.minor version, e.g. + // "v1.15". This excludes the default branch as well as any other + // non-version branch. + if semver.IsValid(name) && semver.MajorMinor(name) == name { + stableBranches = append(stableBranches, name) + } + } + page = resp.NextPage + if page == 0 { + break + } + } + return stableBranches, nil +} + func (ghClient *GHClient) previousVersion(ctx context.Context, owner, repo, currentVersion string) (string, error) { ghTags, err := ghClient.getTags(ctx, owner, repo) if err != nil { diff --git a/cmd/release/issues.go b/cmd/release/issues.go index b8f24abc..5a83c263 100644 --- a/cmd/release/issues.go +++ b/cmd/release/issues.go @@ -8,6 +8,8 @@ import ( "fmt" "net/url" "os" + "sort" + "strings" "github.com/cilium/release/pkg/github" "github.com/cilium/release/pkg/io" @@ -102,9 +104,262 @@ func (c *CheckReleaseBlockers) Run(ctx context.Context, yesToPrompt, _ bool, ghC io.Fprintf(1, os.Stdout, "✅ All backports merged.\n") } + if err := c.checkBackportOrdering(ctx, yesToPrompt, ghClient); err != nil { + return err + } + return nil } +// checkBackportOrdering enforces the invariant that a fix present in a given +// stable branch must also be present in every newer active stable branch. +// Upgrades always move from an older to a newer minor version, so a fix that +// lands in an older branch while still missing from a newer one is an upgrade +// regression. +// +// The check is performed relative to the branch being released (TargetVer), +// covering both directions of the invariant: +// +// - the released branch is behind an OLDER branch: a PR is backport-done on +// an older branch but still needs-backport / backport-pending on the +// released branch; and +// - the released branch is ahead of a NEWER branch: a PR is backport-done on +// the released branch but still needs-backport / backport-pending on a +// newer branch. +// +// Any violation is a hard block on the release process, overridable with +// --force (yesToPrompt). +func (c *CheckReleaseBlockers) checkBackportOrdering(ctx context.Context, yesToPrompt bool, ghClient *GHClient) error { + allStableBranches, err := ghClient.getStableBranches(ctx, c.cfg.Owner, c.cfg.Repo) + if err != nil { + return err + } + + // Cilium only actively maintains the most-recent minors. Comparing against + // EOL branches (which may still carry stale needs-backport labels) would + // produce false positives, so restrict the comparison to the maintained set + // (plus the branch being released). + stableBranches := maintainedStableBranches(allStableBranches, c.cfg.TargetVer, c.cfg.MaintainedMinors) + + queries := backportOrderingQueries(c.cfg.TargetVer, stableBranches, c.cfg.Owner, c.cfg.Repo) + if len(queries) == 0 { + io.Fprintf(1, os.Stdout, "✅ No other actively-maintained stable branches to compare backport ordering against.\n") + return nil + } + + relMM := semver.MajorMinor(c.cfg.TargetVer) + var otherBranches []string + for _, branch := range stableBranches { + if semver.MajorMinor(branch) != relMM { + otherBranches = append(otherBranches, branch) + } + } + io.Fprintf(1, os.Stdout, + "👀 Checking that every backport in %s is consistent with the other actively-maintained stable branches (%s)\n", + relMM, strings.Join(otherBranches, ", ")) + + found, err := c.runBackportOrderingQueries(ctx, ghClient, queries) + if err != nil { + return err + } + if !found { + io.Fprintf(1, os.Stdout, "✅ Backport ordering invariant satisfied across all active stable branches.\n") + return nil + } + + if yesToPrompt { + io.Fprintf(1, os.Stdout, "⏩ --force set, continuing despite backport ordering violations.\n") + return nil + } + return fmt.Errorf("found backport ordering violations. A fix present in one stable branch must also be present in every newer active stable branch. " + + "Please ensure the listed pull requests are backported (or that their backport candidates are frozen consistently) before continuing the release process") +} + +// maintainedStableBranches restricts allBranches (bare major.minor branch names +// such as "v1.15") to the maintainedMinors most-recent minors, always including +// the branch being released (relVer) even if it falls outside that window (e.g. +// when cutting a patch for an about-to-be-EOL branch). The returned slice is +// sorted ascending by version and de-duplicated by major.minor. A +// maintainedMinors <= 0 disables the limit and keeps every branch. It is pure so +// it can be unit-tested without hitting the GitHub API. +func maintainedStableBranches(allBranches []string, relVer string, maintainedMinors int) []string { + relMM := semver.MajorMinor(relVer) + + // Keep only valid major.minor branches, de-duplicated. + seen := make(map[string]struct{}) + var branches []string + for _, b := range allBranches { + mm := semver.MajorMinor(b) + if mm == "" || !semver.IsValid(mm) { + continue + } + if _, ok := seen[mm]; ok { + continue + } + seen[mm] = struct{}{} + branches = append(branches, mm) + } + + // Ensure the released branch is always part of the set. + if relMM != "" { + if _, ok := seen[relMM]; !ok { + branches = append(branches, relMM) + } + } + + // Sort ascending by semver (v1.9 < v1.10). + sort.Slice(branches, func(i, j int) bool { + return semver.Compare(branches[i], branches[j]) < 0 + }) + + // Keep everything when the limit is disabled. + if maintainedMinors <= 0 { + return branches + } + + // Keep the maintainedMinors newest branches, then re-add the released branch + // if the window dropped it. + if len(branches) > maintainedMinors { + branches = branches[len(branches)-maintainedMinors:] + } + if relMM != "" { + found := false + for _, b := range branches { + if b == relMM { + found = true + break + } + } + if !found { + branches = append(branches, relMM) + sort.Slice(branches, func(i, j int) bool { + return semver.Compare(branches[i], branches[j]) < 0 + }) + } + } + return branches +} + +// backportOrderingViolation is a single GitHub search that surfaces pull +// requests violating the backport ordering invariant, together with a +// human-readable reason. +type backportOrderingViolation struct { + reason string + query string +} + +// backportOrderingQueries builds the set of GitHub searches that detect +// backport ordering violations relative to relVer, given the list of active +// stable branches (bare major.minor branch names, e.g. "v1.15"). It is pure so +// it can be unit-tested without hitting the GitHub API. +func backportOrderingQueries(relVer string, stableBranches []string, owner, repo string) []backportOrderingViolation { + relMM := semver.MajorMinor(relVer) + + relDone := github.BackportDoneLabel(relVer) + relNeeds := github.NeedsBackportLabel(relVer) + relPending := github.BackportPendingLabel(relVer) + + var violations []backportOrderingViolation + for _, branch := range stableBranches { + branchMM := semver.MajorMinor(branch) + // Skip the branch being released and anything that is not a valid + // major.minor stable branch. + if branchMM == "" || branchMM == relMM { + continue + } + + branchDone := github.BackportDoneLabel(branch) + branchNeeds := github.NeedsBackportLabel(branch) + branchPending := github.BackportPendingLabel(branch) + + switch { + case semver.Compare(branchMM, relMM) < 0: + // The released branch is behind an OLDER branch: the fix is already + // in the older branch but is still missing from the branch we are + // about to release. + violations = append(violations, + backportOrderingViolation{ + reason: fmt.Sprintf("present in older branch %s (%s) but still needs backport to %s (%s)", branchMM, branchDone, relMM, relNeeds), + query: orderingQuery(owner, repo, branchDone, relNeeds), + }, + backportOrderingViolation{ + reason: fmt.Sprintf("present in older branch %s (%s) but backport to %s is still pending (%s)", branchMM, branchDone, relMM, relPending), + query: orderingQuery(owner, repo, branchDone, relPending), + }, + ) + default: + // The released branch is ahead of a NEWER branch: the fix is in the + // branch we are about to release but is still missing from a newer + // branch. + violations = append(violations, + backportOrderingViolation{ + reason: fmt.Sprintf("present in %s (%s) but still needs backport to newer branch %s (%s)", relMM, relDone, branchMM, branchNeeds), + query: orderingQuery(owner, repo, relDone, branchNeeds), + }, + backportOrderingViolation{ + reason: fmt.Sprintf("present in %s (%s) but backport to newer branch %s is still pending (%s)", relMM, relDone, branchMM, branchPending), + query: orderingQuery(owner, repo, relDone, branchPending), + }, + ) + } + } + return violations +} + +// orderingQuery builds a GitHub issue search that returns merged pull requests +// carrying both presentLabel (the branch where the fix already landed) and +// missingLabel (a branch where the fix is still absent). +func orderingQuery(owner, repo, presentLabel, missingLabel string) string { + return fmt.Sprintf( + "is:pull-request "+ + "is:merged "+ + "label:%s "+ + "label:%s "+ + "repo:%s/%s", + presentLabel, + missingLabel, + owner, + repo, + ) +} + +func (c *CheckReleaseBlockers) runBackportOrderingQueries(ctx context.Context, ghClient *GHClient, violations []backportOrderingViolation) (bool, error) { + var found bool + for _, v := range violations { + page := 0 + var headerPrinted bool + for { + ghIssues, resp, err := ghClient.ghClient.Search.Issues(ctx, v.query, &gh.SearchOptions{ + TextMatch: true, + ListOptions: gh.ListOptions{ + Page: page, + }, + }) + if err != nil { + return found, err + } + if len(ghIssues.Issues) != 0 && !headerPrinted { + headerPrinted = true + if !found { + io.Fprintf(2, os.Stderr, "⚠️ Found backport ordering violations:\n") + } + found = true + io.Fprintf(2, os.Stderr, " • %s:\n", v.reason) + io.Fprintf(3, os.Stderr, "https://github.com/%s/%s/issues?q=%s\n", + c.cfg.Owner, c.cfg.Repo, url.PathEscape(v.query)) + } + for _, ghIssue := range ghIssues.Issues { + io.Fprintf(3, os.Stderr, "%s - %s\n", ghIssue.GetHTMLURL(), ghIssue.GetTitle()) + } + if resp.NextPage == 0 { + break + } + page = resp.NextPage + } + } + return found, nil +} + func (c *CheckReleaseBlockers) checkBackports(ctx context.Context, ghClient *GHClient, query string) (bool, error) { page := 0 var found bool diff --git a/cmd/release/issues_test.go b/cmd/release/issues_test.go new file mode 100644 index 00000000..caa37d60 --- /dev/null +++ b/cmd/release/issues_test.go @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package release + +import ( + "reflect" + "sort" + "testing" +) + +func Test_backportOrderingQueries(t *testing.T) { + const ( + owner = "cilium" + repo = "cilium" + ) + + type args struct { + relVer string + stableBranches []string + } + tests := []struct { + name string + args args + want []backportOrderingViolation + }{ + { + name: "no other stable branches", + args: args{ + relVer: "v1.15.5", + stableBranches: []string{"v1.15"}, + }, + want: nil, + }, + { + name: "released branch is the newest: only older branches, behind-older direction", + args: args{ + relVer: "v1.15.5", + stableBranches: []string{"v1.13", "v1.14", "v1.15"}, + }, + want: []backportOrderingViolation{ + // v1.13 is older than v1.15 + { + reason: "present in older branch v1.13 (backport-done/1.13) but still needs backport to v1.15 (needs-backport/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.13", "needs-backport/1.15"), + }, + { + reason: "present in older branch v1.13 (backport-done/1.13) but backport to v1.15 is still pending (backport-pending/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.13", "backport-pending/1.15"), + }, + // v1.14 is older than v1.15 + { + reason: "present in older branch v1.14 (backport-done/1.14) but still needs backport to v1.15 (needs-backport/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.14", "needs-backport/1.15"), + }, + { + reason: "present in older branch v1.14 (backport-done/1.14) but backport to v1.15 is still pending (backport-pending/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.14", "backport-pending/1.15"), + }, + }, + }, + { + name: "released branch is the oldest: only newer branches, ahead-of-newer direction", + args: args{ + relVer: "v1.13.20", + stableBranches: []string{"v1.13", "v1.14", "v1.15"}, + }, + want: []backportOrderingViolation{ + // v1.14 is newer than v1.13 + { + reason: "present in v1.13 (backport-done/1.13) but still needs backport to newer branch v1.14 (needs-backport/1.14)", + query: orderingQuery(owner, repo, "backport-done/1.13", "needs-backport/1.14"), + }, + { + reason: "present in v1.13 (backport-done/1.13) but backport to newer branch v1.14 is still pending (backport-pending/1.14)", + query: orderingQuery(owner, repo, "backport-done/1.13", "backport-pending/1.14"), + }, + // v1.15 is newer than v1.13 + { + reason: "present in v1.13 (backport-done/1.13) but still needs backport to newer branch v1.15 (needs-backport/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.13", "needs-backport/1.15"), + }, + { + reason: "present in v1.13 (backport-done/1.13) but backport to newer branch v1.15 is still pending (backport-pending/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.13", "backport-pending/1.15"), + }, + }, + }, + { + name: "released branch in the middle: both directions", + args: args{ + relVer: "v1.14.10", + stableBranches: []string{"v1.13", "v1.14", "v1.15"}, + }, + want: []backportOrderingViolation{ + // v1.13 is older than v1.14 + { + reason: "present in older branch v1.13 (backport-done/1.13) but still needs backport to v1.14 (needs-backport/1.14)", + query: orderingQuery(owner, repo, "backport-done/1.13", "needs-backport/1.14"), + }, + { + reason: "present in older branch v1.13 (backport-done/1.13) but backport to v1.14 is still pending (backport-pending/1.14)", + query: orderingQuery(owner, repo, "backport-done/1.13", "backport-pending/1.14"), + }, + // v1.15 is newer than v1.14 + { + reason: "present in v1.14 (backport-done/1.14) but still needs backport to newer branch v1.15 (needs-backport/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.14", "needs-backport/1.15"), + }, + { + reason: "present in v1.14 (backport-done/1.14) but backport to newer branch v1.15 is still pending (backport-pending/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.14", "backport-pending/1.15"), + }, + }, + }, + { + name: "ignores non-version branches such as the default branch", + args: args{ + relVer: "v1.15.5", + stableBranches: []string{"main", "v1.14", "v1.15", "some-feature-branch"}, + }, + want: []backportOrderingViolation{ + { + reason: "present in older branch v1.14 (backport-done/1.14) but still needs backport to v1.15 (needs-backport/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.14", "needs-backport/1.15"), + }, + { + reason: "present in older branch v1.14 (backport-done/1.14) but backport to v1.15 is still pending (backport-pending/1.15)", + query: orderingQuery(owner, repo, "backport-done/1.14", "backport-pending/1.15"), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := backportOrderingQueries(tt.args.relVer, tt.args.stableBranches, owner, repo) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("backportOrderingQueries() mismatch\n got: %#v\nwant: %#v", got, tt.want) + } + }) + } +} + +func Test_maintainedStableBranches(t *testing.T) { + tests := []struct { + name string + allBranches []string + relVer string + maintainedMinors int + want []string + }{ + { + name: "trims to N newest, releasing the newest", + allBranches: []string{"v1.11", "v1.12", "v1.13", "v1.14", "v1.15"}, + relVer: "v1.15.3", + maintainedMinors: 3, + want: []string{"v1.13", "v1.14", "v1.15"}, + }, + { + name: "released branch outside the window is re-added", + allBranches: []string{"v1.11", "v1.12", "v1.13", "v1.14", "v1.15"}, + relVer: "v1.12.9", + maintainedMinors: 3, + want: []string{"v1.12", "v1.13", "v1.14", "v1.15"}, + }, + { + name: "released branch not among protected branches is added", + allBranches: []string{"v1.13", "v1.14"}, + relVer: "v1.15.0", + maintainedMinors: 3, + want: []string{"v1.13", "v1.14", "v1.15"}, + }, + { + name: "limit disabled keeps all, sorted and de-duplicated", + allBranches: []string{"v1.15", "v1.11", "v1.13", "v1.15", "v1.12", "v1.14"}, + relVer: "v1.15.3", + maintainedMinors: 0, + want: []string{"v1.11", "v1.12", "v1.13", "v1.14", "v1.15"}, + }, + { + name: "sorts numerically, not lexically (v1.9 < v1.10)", + allBranches: []string{"v1.9", "v1.10", "v1.11"}, + relVer: "v1.11.0", + maintainedMinors: 2, + want: []string{"v1.10", "v1.11"}, + }, + { + name: "ignores non-version branches", + allBranches: []string{"main", "v1.14", "feature-x", "v1.15"}, + relVer: "v1.15.1", + maintainedMinors: 3, + want: []string{"v1.14", "v1.15"}, + }, + { + name: "patch-level relVer normalizes to major.minor", + allBranches: []string{"v1.14", "v1.15"}, + relVer: "v1.15.7", + maintainedMinors: 3, + want: []string{"v1.14", "v1.15"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := maintainedStableBranches(tt.allBranches, tt.relVer, tt.maintainedMinors) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("maintainedStableBranches() = %v, want %v", got, tt.want) + } + }) + } +} + +// Test_backportOrderingQueries_coversAllPairs is a property-style check that, for +// any release branch, every other active stable branch is compared exactly once +// in the correct direction and no self-comparison is generated. +func Test_backportOrderingQueries_coversAllPairs(t *testing.T) { + stableBranches := []string{"v1.12", "v1.13", "v1.14", "v1.15", "v1.16"} + for _, relVer := range []string{"v1.12.9", "v1.13.5", "v1.14.3", "v1.15.1", "v1.16.0"} { + got := backportOrderingQueries(relVer, stableBranches, "cilium", "cilium") + // Each other branch contributes exactly 2 queries (needs + pending). + wantLen := (len(stableBranches) - 1) * 2 + if len(got) != wantLen { + t.Fatalf("relVer=%s: got %d queries, want %d", relVer, len(got), wantLen) + } + // Ensure no query compares a branch against itself. + for _, v := range got { + if v.query == "" { + t.Fatalf("relVer=%s: empty query in %#v", relVer, v) + } + } + } +} + +// Test_backportOrderingQueries_unaffectedByBranchOrder ensures the output does +// not depend on the ordering the branches are returned by the GitHub API in. +func Test_backportOrderingQueries_unaffectedByBranchOrder(t *testing.T) { + relVer := "v1.14.10" + ascending := []string{"v1.13", "v1.14", "v1.15"} + descending := []string{"v1.15", "v1.14", "v1.13"} + + gotAsc := backportOrderingQueries(relVer, ascending, "cilium", "cilium") + gotDesc := backportOrderingQueries(relVer, descending, "cilium", "cilium") + + // The set of produced queries must be identical regardless of input order. + sortQueries := func(vs []backportOrderingViolation) []string { + out := make([]string, 0, len(vs)) + for _, v := range vs { + out = append(out, v.query) + } + sort.Strings(out) + return out + } + + if !reflect.DeepEqual(sortQueries(gotAsc), sortQueries(gotDesc)) { + t.Errorf("query set depends on input branch order:\n asc: %v\ndesc: %v", + sortQueries(gotAsc), sortQueries(gotDesc)) + } +} diff --git a/cmd/release/start.go b/cmd/release/start.go index 590d549e..4e1abc24 100644 --- a/cmd/release/start.go +++ b/cmd/release/start.go @@ -41,6 +41,7 @@ type ReleaseConfig struct { StateFile string Steps []string DefaultBranch string + MaintainedMinors int IncludeLabels []string ExcludeLabels []string @@ -323,6 +324,7 @@ To start, run cmd.Flags().StringVar(&cfg.HelmRepoDirectory, "charts-repo-dir", "../charts", "Directory with the source code of Helm charts") cmd.Flags().StringSliceVar(&cfg.HelmOCIRegistries, "helm-oci-registries", []string{"oci://quay.io/cilium/charts"}, "OCI registry URLs for Helm charts (comma-separated)") cmd.Flags().StringVar(&cfg.StateFile, "state-file", defaultStateFileValue, "When set, it will use the already fetched information from a previous run") + cmd.Flags().IntVar(&cfg.MaintainedMinors, "maintained-minors", 3, "Number of most-recent stable minor branches considered actively maintained. The backport-ordering check only compares against these branches (plus the released one), ignoring older EOL branches.") cmd.Flags().StringSliceVar(&cfg.Steps, "steps", []string{"1"}, fmt.Sprintf("Specify which steps should be executed for the release. Steps numbers are also allowed, e.g. '1,2'. Accepted values: %s", strings.Join(allGroupStepsNames, ", ")), ) diff --git a/pkg/github/labels.go b/pkg/github/labels.go index ad8b6223..e0366802 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -172,9 +172,11 @@ func parseGHLabels(ghLabels []*gh.Label) []string { } const ( - releaseBlockerPrefix = "release-blocker/" - backportDonePrefix = "backport-done/" - backportPrefix = "backport/" + releaseBlockerPrefix = "release-blocker/" + backportDonePrefix = "backport-done/" + backportPendingPrefix = "backport-pending/" + needsBackportPrefix = "needs-backport/" + backportPrefix = "backport/" ) func ReleaseBlockerLabel(version string) string { @@ -185,6 +187,18 @@ func BackportDoneLabel(version string) string { return fmt.Sprintf("%s%s", backportDonePrefix, MajorMinorErsion(version)) } +// BackportPendingLabel returns the label set on an upstream PR whose backport to +// the given version has an open (pending) backport PR. +func BackportPendingLabel(version string) string { + return fmt.Sprintf("%s%s", backportPendingPrefix, MajorMinorErsion(version)) +} + +// NeedsBackportLabel returns the label set on an upstream PR that still needs to +// be backported to the given version. +func NeedsBackportLabel(version string) string { + return fmt.Sprintf("%s%s", needsBackportPrefix, MajorMinorErsion(version)) +} + func BackportLabel(version string) string { return fmt.Sprintf("%s%s", backportPrefix, MajorMinorErsion(version)) } diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go index 4a66119e..95454753 100644 --- a/pkg/github/labels_test.go +++ b/pkg/github/labels_test.go @@ -89,6 +89,63 @@ func Test_getReleaseNote(t *testing.T) { } } +func Test_backportLabelHelpers(t *testing.T) { + tests := []struct { + version string + wantDone string + wantPending string + wantNeeds string + wantBackport string + wantReleaseBlck string + }{ + { + version: "v1.15.5", + wantDone: "backport-done/1.15", + wantPending: "backport-pending/1.15", + wantNeeds: "needs-backport/1.15", + wantBackport: "backport/1.15", + wantReleaseBlck: "release-blocker/1.15", + }, + { + // Bare major.minor branch names should work as well. + version: "v1.14", + wantDone: "backport-done/1.14", + wantPending: "backport-pending/1.14", + wantNeeds: "needs-backport/1.14", + wantBackport: "backport/1.14", + wantReleaseBlck: "release-blocker/1.14", + }, + { + // Pre-releases collapse to their major.minor. + version: "v1.16.0-rc.1", + wantDone: "backport-done/1.16", + wantPending: "backport-pending/1.16", + wantNeeds: "needs-backport/1.16", + wantBackport: "backport/1.16", + wantReleaseBlck: "release-blocker/1.16", + }, + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + if got := BackportDoneLabel(tt.version); got != tt.wantDone { + t.Errorf("BackportDoneLabel(%q) = %q, want %q", tt.version, got, tt.wantDone) + } + if got := BackportPendingLabel(tt.version); got != tt.wantPending { + t.Errorf("BackportPendingLabel(%q) = %q, want %q", tt.version, got, tt.wantPending) + } + if got := NeedsBackportLabel(tt.version); got != tt.wantNeeds { + t.Errorf("NeedsBackportLabel(%q) = %q, want %q", tt.version, got, tt.wantNeeds) + } + if got := BackportLabel(tt.version); got != tt.wantBackport { + t.Errorf("BackportLabel(%q) = %q, want %q", tt.version, got, tt.wantBackport) + } + if got := ReleaseBlockerLabel(tt.version); got != tt.wantReleaseBlck { + t.Errorf("ReleaseBlockerLabel(%q) = %q, want %q", tt.version, got, tt.wantReleaseBlck) + } + }) + } +} + func Test_getBackportPRs(t *testing.T) { type args struct { body string