internal/task: add workflow to build internal security release branches Take security patches in the internal gerrit and from them build the internal release branches that are used to build a security release. Updates golang/go#59717 Change-Id: I2eb4e9fda773e49fbde1fe967678ab3cc813bac8 Reviewed-on: https://go-review.googlesource.com/c/build/+/612116 Reviewed-by: Dmitri Shuralyov <dmitshur@google.com> Auto-Submit: Roland Shoemaker <roland@golang.org> Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/cmd/relui/main.go b/cmd/relui/main.go index 86ae224..3d47371 100644 --- a/cmd/relui/main.go +++ b/cmd/relui/main.go
@@ -365,6 +365,12 @@ } dh.RegisterDefinition("Publish a private patch to a x/ repo", privateXPatchTask.NewDefinition(tagTasks)) + securityReleaseCoalesceTask := &task.SecurityReleaseCoalesceTask{ + PrivateGerrit: privateGerritClient, + Version: versionTasks, + } + dh.RegisterDefinition("Prepare internal security release branches", securityReleaseCoalesceTask.NewDefinition()) + var base *url.URL if *baseURL != "" { base, err = url.Parse(*baseURL)
diff --git a/gerrit/gerrit.go b/gerrit/gerrit.go index fd2d4d9..8710f3a 100644 --- a/gerrit/gerrit.go +++ b/gerrit/gerrit.go
@@ -326,6 +326,9 @@ // MoreChanges is set on the last change from QueryChanges if // the result set is truncated by an 'n' parameter. MoreChanges bool `json:"_more_changes"` + + // ContainsGitConflicts indicates if the change has merge conflicts. + ContainsGitConflicts bool `json:"contains_git_conflicts"` } // ReviewerUpdateInfo is a Gerrit data structure. @@ -1204,3 +1207,75 @@ err := c.do(ctx, &result, "GET", "/projects/"+url.PathEscape(project)+"/commits:in", urlValues(vals)) return result, err } + +// CherryPickRevision cherry picks a change revision to a destination branch. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherry-pick. +func (c *Client) CherryPickRevision(ctx context.Context, changeID, revisionID string, cpi CherryPickInput) (ChangeInfo, error) { + var change ChangeInfo + err := c.do(ctx, &change, "POST", "/changes/"+changeID+"/revisions/"+revisionID+"/cherrypick", reqBodyJSON{&cpi}, wantResStatus(http.StatusOK)) + return change, err +} + +// CherryPickInput contains the options for creating a new cherry-pick. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherrypick-input. +type CherryPickInput struct { + Destination string `json:"destination"` + KeepReviewers bool `json:"keep_reviewers"` + AllowConflicts bool `json:"allow_conflicts"` + Message string `json:"message,omitempty"` +} + +// MoveChange moves a change onto a destination branch. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#move-change. +func (c *Client) MoveChange(ctx context.Context, changeID string, mi MoveInput) (ChangeInfo, error) { + var change ChangeInfo + err := c.do(ctx, &change, "POST", "/changes/"+changeID+"/move", reqBodyJSON{&mi}, wantResStatus(http.StatusOK)) + return change, err +} + +// MoveInput contains the options for moving a change. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#move-input. +type MoveInput struct { + DestinationBranch string `json:"destination_branch"` + KeepAllVotes bool `json:"keep_all_votes"` +} + +// RebaseChange rebases a change onto a new base revision, or directly on top of +// the target branch. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#rebase-change. +func (c *Client) RebaseChange(ctx context.Context, changeID string, ri RebaseInput) (ChangeInfo, error) { + var change ChangeInfo + err := c.do(ctx, &change, "POST", "/changes/"+changeID+"/rebase", reqBodyJSON{&ri}, wantResStatus(http.StatusOK)) + return change, err +} + +// RebaseInput contains the options for rebasing a change. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#rebase-input. +type RebaseInput struct { + Base string `json:"base,omitempty"` + AllowConflicts bool `json:"allow_conflicts"` +} + +// GetCommitMessage retrieves the commit message for a change. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-message. +func (c *Client) GetCommitMessage(ctx context.Context, changeID string) (CommitMessageInfo, error) { + var cmi CommitMessageInfo + err := c.do(ctx, &cmi, "GET", "/changes/"+changeID+"/message") + return cmi, err +} + +// CommitMessageInfo contains information about a commit message. +// +// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-message-info. +type CommitMessageInfo struct { + Subject string `json:"subject"` + FullMessage string `json:"full_message"` + Footers map[string]string `json:"footers"` +}
diff --git a/internal/task/fakes.go b/internal/task/fakes.go index 94445d4..b670a1e 100644 --- a/internal/task/fakes.go +++ b/internal/task/fakes.go
@@ -520,6 +520,30 @@ return nil, nil } +func (*FakeGerrit) SubmitChange(ctx context.Context, changeID string) (gerrit.ChangeInfo, error) { + return gerrit.ChangeInfo{}, nil +} + +func (*FakeGerrit) CreateCherryPick(ctx context.Context, changeID string, branch string, message string) (gerrit.ChangeInfo, bool, error) { + return gerrit.ChangeInfo{}, false, nil +} + +func (*FakeGerrit) MoveChange(ctx context.Context, changeID string, branch string, keepAllVotes bool) (gerrit.ChangeInfo, error) { + return gerrit.ChangeInfo{}, nil +} + +func (*FakeGerrit) RebaseChange(ctx context.Context, changeID string, baseRev string) (gerrit.ChangeInfo, error) { + return gerrit.ChangeInfo{}, nil +} + +func (*FakeGerrit) GetRevisionActions(ctx context.Context, changeID, revision string) (map[string]*gerrit.ActionInfo, error) { + return map[string]*gerrit.ActionInfo{}, nil +} + +func (*FakeGerrit) GetCommitMessage(ctx context.Context, changeID string) (string, error) { + return "", nil +} + // NewFakeSignService returns a fake signing service that can sign PKGs, MSIs, // and generate GPG signatures. MSIs are "signed" by adding a suffix to them. // PKGs must actually be tarballs with a prefix of "I'm a PKG!\n". Any files
diff --git a/internal/task/gerrit.go b/internal/task/gerrit.go index bd8bae8..25611ff 100644 --- a/internal/task/gerrit.go +++ b/internal/task/gerrit.go
@@ -64,6 +64,22 @@ SetHashtags(ctx context.Context, changeID string, hashtags gerrit.HashtagsInput) error // GetChange gets information about a specific change. GetChange(ctx context.Context, changeID string, opts ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) + // SubmitChange submits a specific change. + SubmitChange(ctx context.Context, changeID string) (gerrit.ChangeInfo, error) + // CreateCherryPick creates a cherry-pick change. If there are no merge + // conflicts, it starts trybots. If commitMessage is provided, the commit + // message is updated, otherwise it is taken from the original change. + // Reviewers are taken from the original change. + CreateCherryPick(ctx context.Context, changeID string, branch string, commitMessage string) (_ gerrit.ChangeInfo, conflicts bool, _ error) + // RebaseChange rebases a change onto a base revision. If revision is empty, + // the change is rebased directly on top of the target branch. + RebaseChange(ctx context.Context, changeID string, revision string) (gerrit.ChangeInfo, error) + // MoveChange moves a change onto a new branch. + MoveChange(ctx context.Context, changeID string, branch string, keepAllVotes bool) (gerrit.ChangeInfo, error) + // GetRevisionActions retrieves revision actions. + GetRevisionActions(ctx context.Context, changeID, revision string) (map[string]*gerrit.ActionInfo, error) + // GetCommitMessage retrieves the commit message for a change. + GetCommitMessage(ctx context.Context, changeID string) (string, error) } type RealGerritClient struct { @@ -311,3 +327,46 @@ _, err := c.Client.SetHashtags(ctx, changeID, hashtags) return err } + +func (c *RealGerritClient) SubmitChange(ctx context.Context, changeID string) (gerrit.ChangeInfo, error) { + return c.Client.SubmitChange(ctx, changeID) +} + +func (c *RealGerritClient) CreateCherryPick(ctx context.Context, changeID string, branch string, commitMessage string) (gerrit.ChangeInfo, bool, error) { + cpi := gerrit.CherryPickInput{Destination: branch, KeepReviewers: true, AllowConflicts: true, Message: commitMessage} + ci, err := c.Client.CherryPickRevision(ctx, changeID, "current", cpi) + if err != nil { + return gerrit.ChangeInfo{}, false, err + } + if ci.ContainsGitConflicts { + return ci, true, nil + } + if err := c.Client.SetReview(ctx, ci.ID, "current", gerrit.ReviewInput{ + Labels: map[string]int{ + "Commit-Queue": 1, + }, + }); err != nil { + return gerrit.ChangeInfo{}, false, err + } + return ci, false, nil +} + +func (c *RealGerritClient) MoveChange(ctx context.Context, changeID string, branch string, keepAllVotes bool) (gerrit.ChangeInfo, error) { + return c.Client.MoveChange(ctx, changeID, gerrit.MoveInput{DestinationBranch: branch, KeepAllVotes: keepAllVotes}) +} + +func (c *RealGerritClient) RebaseChange(ctx context.Context, changeID string, base string) (gerrit.ChangeInfo, error) { + return c.Client.RebaseChange(ctx, changeID, gerrit.RebaseInput{Base: base, AllowConflicts: true}) +} + +func (c *RealGerritClient) GetRevisionActions(ctx context.Context, changeID, revision string) (map[string]*gerrit.ActionInfo, error) { + return c.Client.GetRevisionActions(ctx, changeID, revision) +} + +func (c *RealGerritClient) GetCommitMessage(ctx context.Context, changeID string) (string, error) { + cmi, err := c.Client.GetCommitMessage(ctx, changeID) + if err != nil { + return "", err + } + return cmi.FullMessage, nil +}
diff --git a/internal/task/security_release_coalesce.go b/internal/task/security_release_coalesce.go new file mode 100644 index 0000000..9eebe6b --- /dev/null +++ b/internal/task/security_release_coalesce.go
@@ -0,0 +1,255 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package task + +import ( + "errors" + "fmt" + goversion "go/version" + "regexp" + "strings" + "time" + + "golang.org/x/build/gerrit" + "golang.org/x/build/internal/relui/groups" + wf "golang.org/x/build/internal/workflow" +) + +// SecurityReleaseCoalesceTask is the workflow used to preparing patches for +// minor security releases. The workflow is described in detail in +// go/go-security-release-workflow. +// +// In short, this workflow: +// 1. Checks that all patches are ready, indicated by two Code-Review+2's labels +// and a Security-Patch-Ready+1 label (this is checked via submit requirements +// rather than directly inspecting the labels) and lack of merge conflicts +// 2. Creates a new branch from master HEAD +// 3. Moves all patches from master onto the new branch +// 4. Submits the rebased patches +// 5. Create internal release branches +// 6. Creates cherry-picks of the submitted patches onto the two release branches, +// setting Commit-Queue+1 +type SecurityReleaseCoalesceTask struct { + PrivateGerrit GerritClient + Version *VersionTasks +} + +func (x *SecurityReleaseCoalesceTask) NewDefinition() *wf.Definition { + // TODO: this is currently not particularly tolerant of failures that happen + // half way through the workflow. Will need to think a bit about how we can + // recover in failure situations that doesn't require manually cleaning a + // bunch of stuff up before re-running the workflow. + + var numOnlyRe = regexp.MustCompile(`^\d+$`) + + wd := wf.New(wf.ACL{Groups: []string{groups.SecurityTeam}}) + clNums := wf.Param(wd, wf.ParamDef[[]string]{ + Name: "Security Patch CL Numbers", + ParamType: wf.SliceShort, + Doc: `Gerrit CL numbers for each security patch in a release`, + Example: "123456", + Check: func(nums []string) error { + for _, num := range nums { + if !numOnlyRe.MatchString(num) { + return errors.New("CL numbers must contain only numbers") + } + } + return nil + }, + }) + + // check CLs are ready + cls := wf.Task1(wd, "Check changes", x.CheckChanges, clNums) + // look up branch names + branchInfo := wf.Task0(wd, "Get branch names", x.GetBranchNames, wf.After(cls)) + // create checkpoint branch + checkpointBranch := wf.Task1(wd, "Create checkpoint branch", x.CreateCheckpoint, branchInfo) + // rebase changes to checkpoint branch + cls = wf.Task2(wd, "Move changes onto checkpoint branch", x.MoveAndRebaseChanges, checkpointBranch, cls) + // wait for changes to be submittable, and then submit them + cls = wf.Task1(wd, "Await submissions", x.WaitAndSubmit, cls) + // create internal release branches + internalReleaseBranches := wf.Task1(wd, "Create internal release branches", x.CreateInternalReleaseBranches, branchInfo, wf.After(cls)) + // create cherry-picks to internal release branches + cherryPicks := wf.Task2(wd, "Create cherry-picks", x.CreateCherryPicks, internalReleaseBranches, cls) + wf.Output(wd, "Cherry-picks", cherryPicks) + + return wd +} + +type branchInfo struct { + CheckpointName string + PublicReleaseBranches []string +} + +func (x *SecurityReleaseCoalesceTask) GetBranchNames(ctx *wf.TaskContext) (branchInfo, error) { + currentMajor, _, err := x.Version.GetCurrentMajor(ctx.Context) + if err != nil { + return branchInfo{}, err + } + nextMinors, err := x.Version.GetNextMinorVersions(ctx.Context, []int{currentMajor, currentMajor - 1}) + if err != nil { + return branchInfo{}, err + } + + return branchInfo{ + CheckpointName: fmt.Sprintf("%s-and-%s-checkpoint", nextMinors[0], nextMinors[1]), + PublicReleaseBranches: []string{ + fmt.Sprintf("release-branch.%s", nextMinors[0]), + fmt.Sprintf("release-branch.%s", nextMinors[1]), + }, + }, nil +} + +func (x *SecurityReleaseCoalesceTask) CheckChanges(ctx *wf.TaskContext, clNums []string) ([]*gerrit.ChangeInfo, error) { + var cls []*gerrit.ChangeInfo + for _, num := range clNums { + ci, err := x.PrivateGerrit.GetChange(ctx.Context, num, gerrit.QueryChangesOpt{Fields: []string{"SUBMITTABLE"}}) + if err != nil { + return nil, err + } + if !ci.Submittable { + return nil, fmt.Errorf("Change %s is not submittable", internalGerritChangeURL(num)) + } + ra, err := x.PrivateGerrit.GetRevisionActions(ctx.Context, num, "current") + if err != nil { + return nil, err + } + if ra["submit"] == nil || !ra["submit"].Enabled { + return nil, fmt.Errorf("Change %s is not submittable", internalGerritChangeURL(num)) + } + cls = append(cls, ci) + } + return cls, nil +} + +func (x *SecurityReleaseCoalesceTask) CreateCheckpoint(ctx *wf.TaskContext, bi branchInfo) (string, error) { + publicHead, err := x.PrivateGerrit.ReadBranchHead(ctx.Context, "go", "public") + if err != nil { + return "", err + } + if _, err := x.PrivateGerrit.CreateBranch(ctx.Context, "go", bi.CheckpointName, gerrit.BranchInput{Revision: publicHead}); err != nil { + return "", err + } + return bi.CheckpointName, nil +} + +func (x *SecurityReleaseCoalesceTask) MoveAndRebaseChanges(ctx *wf.TaskContext, checkpointBranch string, cls []*gerrit.ChangeInfo) ([]*gerrit.ChangeInfo, error) { + for i, ci := range cls { + newCI, err := x.PrivateGerrit.MoveChange(ctx.Context, ci.ChangeID, checkpointBranch, true) + if err != nil { + return nil, err + } + newCI, err = x.PrivateGerrit.RebaseChange(ctx.Context, ci.ChangeID, "") + if err != nil { + return nil, err + } + + cls[i] = &newCI + } + return cls, nil +} + +func (x *SecurityReleaseCoalesceTask) WaitAndSubmit(ctx *wf.TaskContext, cls []*gerrit.ChangeInfo) ([]*gerrit.ChangeInfo, error) { + if _, err := AwaitCondition(ctx, time.Second*10, func() (string, bool, error) { + unsubmitted := len(cls) + + for i, change := range cls { + if change.Status == gerrit.ChangeStatusMerged { + unsubmitted-- + continue + } + + ci, err := x.PrivateGerrit.GetChange(ctx, change.ChangeID, gerrit.QueryChangesOpt{Fields: []string{"SUBMITTABLE"}}) + if err != nil { + return "", false, err + } + + if !ci.Submittable { + continue + } + + submitted, err := x.PrivateGerrit.SubmitChange(ctx, ci.ChangeID) + if err != nil { + return "", false, err + } + + cls[i] = &submitted + unsubmitted-- + } + + if unsubmitted == 0 { + return "", true, nil + } + return "", false, nil + }); err != nil { + return nil, err + } + + return cls, nil +} + +// majorFromMinor converts a release branch name from it's minor version form to +// it's major version form (i.e. release-branch.go1.2.3 to +// release-branch.go1.2). +func majorFromMinor(branch string) string { + stripped := strings.TrimPrefix(branch, "release-branch.") + major := goversion.Lang(stripped) + return "release-branch." + major +} + +var internalReleaseBranchPrefix = "internal-" + +func (x *SecurityReleaseCoalesceTask) CreateInternalReleaseBranches(ctx *wf.TaskContext, bi branchInfo) ([]string, error) { + var internalBranches []string + for _, nextMinor := range bi.PublicReleaseBranches { + publicHead, err := x.PrivateGerrit.ReadBranchHead(ctx.Context, "go", majorFromMinor(nextMinor)) + if err != nil { + return nil, err + } + internalReleaseBranch := internalReleaseBranchPrefix + nextMinor + if _, err := x.PrivateGerrit.CreateBranch(ctx.Context, "go", internalReleaseBranch, gerrit.BranchInput{Revision: publicHead}); err != nil { + return nil, err + } + internalBranches = append(internalBranches, internalReleaseBranch) + } + return internalBranches, nil +} + +func (x *SecurityReleaseCoalesceTask) CreateCherryPicks(ctx *wf.TaskContext, releaseBranches []string, cls []*gerrit.ChangeInfo) (map[string][]string, error) { + // TODO: this currently assumes we want to cherry-pick everything to both + // branches, which is _normally_ the case, but sometimes is not accurate. We + // can manually just abandon cherry-picks we don't care about, but probably + // we should have a way to indicate which branches we want each patch + // cherry-picked onto. + + cherryPicks := map[string][]string{} + for _, ci := range cls { + for _, releaseBranch := range releaseBranches { + commitMessage, err := x.PrivateGerrit.GetCommitMessage(ctx.Context, ci.ID) + if err != nil { + return nil, err + } + // TODO: might be cleaner to just pass this information from CreateInternalReleaseBranches + commitMessage = fmt.Sprintf("[%s] %s", majorFromMinor(strings.TrimPrefix(releaseBranch, internalReleaseBranchPrefix)), commitMessage) + + cpCI, conflicts, err := x.PrivateGerrit.CreateCherryPick(ctx.Context, ci.ID, releaseBranch, commitMessage) + if err != nil { + return nil, err + } + if conflicts { + ctx.Logger.Printf("Cherry-pick of %d has merge conflicts against %s: %s", internalGerritChangeURL(ci.ChangeNumber), releaseBranch, internalGerritChangeURL(cpCI.ChangeNumber)) + } + cherryPicks[releaseBranch] = append(cherryPicks[releaseBranch], internalGerritChangeURL(cpCI.ChangeNumber)) + } + } + return cherryPicks, nil +} + +// internalGerritChangeURL can take either a int or string and return the +// relevant CL URL for a change number. +func internalGerritChangeURL[T int | string](clNum T) string { + return fmt.Sprintf("https://go-internal-review.git.corp.google.com/c/go/+/%v", clNum) +}
diff --git a/internal/task/security_release_coalesce_test.go b/internal/task/security_release_coalesce_test.go new file mode 100644 index 0000000..5f75bbb --- /dev/null +++ b/internal/task/security_release_coalesce_test.go
@@ -0,0 +1,213 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package task + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "slices" + "strings" + "testing" + "time" + + "golang.org/x/build/gerrit" + wf "golang.org/x/build/internal/workflow" +) + +type fakeCoalesceGerrit struct { + *FakeGerrit + + changes map[string]*gerrit.ChangeInfo + commitMessages map[string]string + cherryPicks map[string][]cherryPickedCommit +} + +type cherryPickedCommit struct { + changeID string + message string +} + +func (g *fakeCoalesceGerrit) GetChange(_ context.Context, changeID string, _ ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) { + ci, ok := g.changes[changeID] + if !ok { + return nil, errors.New("GetChange: not found") + } + return ci, nil +} + +func (g *fakeCoalesceGerrit) GetRevisionActions(_ context.Context, changeID string, revision string) (map[string]*gerrit.ActionInfo, error) { + if _, ok := g.changes[changeID]; !ok { + return nil, nil + } + action := &gerrit.ActionInfo{Enabled: true} + return map[string]*gerrit.ActionInfo{"submit": action}, nil +} + +func (g *fakeCoalesceGerrit) MoveChange(ctx context.Context, changeID string, branch string, keepAllVotes bool) (gerrit.ChangeInfo, error) { + ci, ok := g.changes[changeID] + if !ok { + return gerrit.ChangeInfo{}, errors.New("MoveChange: not found") + } + ci.Branch = branch + return *ci, nil +} + +func (g *fakeCoalesceGerrit) SubmitChange(ctx context.Context, changeID string) (gerrit.ChangeInfo, error) { + ci, ok := g.changes[changeID] + if !ok { + return gerrit.ChangeInfo{}, errors.New("SubmitChange: not found") + } + + r := make([]byte, 4) + rand.Read(r) + g.repos["go"].CommitOnBranch(ci.Branch, map[string]string{"patch": fmt.Sprintf("%x", r)}) + + ci.Status = gerrit.ChangeStatusMerged + + return *ci, nil +} + +func (g *fakeCoalesceGerrit) RebaseChange(ctx context.Context, changeID string, baseRev string) (gerrit.ChangeInfo, error) { + return *g.changes[changeID], nil +} + +func (g *fakeCoalesceGerrit) CreateCherryPick(ctx context.Context, changeID string, branch string, message string) (gerrit.ChangeInfo, bool, error) { + ci, ok := g.changes[changeID] + if !ok { + return gerrit.ChangeInfo{}, false, errors.New("CreateCherryPick: not found") + } + + g.cherryPicks[branch] = append(g.cherryPicks[branch], cherryPickedCommit{ci.ChangeID, message}) + return *ci, false, nil +} + +func (g *fakeCoalesceGerrit) GetCommitMessage(ctx context.Context, changeID string) (string, error) { + return g.commitMessages[changeID], nil +} + +type securityVersionClient struct { + GerritClient + tags []string +} + +func (c *securityVersionClient) ListTags(ctx context.Context, project string) ([]string, error) { + return c.tags, nil +} + +func (c *securityVersionClient) GetTag(ctx context.Context, project, tag string) (gerrit.TagInfo, error) { + for _, t := range c.tags { + if tag == t { + return gerrit.TagInfo{Created: gerrit.TimeStamp(time.Now())}, nil + } + } + return gerrit.TagInfo{}, errors.New("not found") +} + +func TestSecurityReleaseCoalesceTask(t *testing.T) { + privRepo := NewFakeRepo(t, "go") + privGerrit := &fakeCoalesceGerrit{FakeGerrit: NewFakeGerrit(t, privRepo), cherryPicks: map[string][]cherryPickedCommit{}, commitMessages: map[string]string{}} + task := &SecurityReleaseCoalesceTask{ + PrivateGerrit: privGerrit, + Version: &VersionTasks{ + Gerrit: &securityVersionClient{ + tags: []string{"go1.3", "go1.3.1", "go1.4", "go1.4.1"}, + }, + GoProject: "go", + }, + } + + privGerrit.changes = map[string]*gerrit.ChangeInfo{ + "1234": { + ID: "1234", + ChangeID: "1234", + ChangeNumber: 1234, + Branch: "public", + Submittable: true, + Mergeable: true, + }, + "5678": { + ID: "5678", + ChangeID: "5678", + ChangeNumber: 5678, + Branch: "public", + Submittable: true, + Mergeable: true, + }, + } + + privGerrit.commitMessages = map[string]string{ + "1234": `subject: 1234 + +body`, + "5678": `subject: 5678 + +other body`, + } + + head := privRepo.History()[0] + privRepo.Branch("public", head) + privRepo.Branch("release-branch.go1.3", head) + privRepo.Branch("release-branch.go1.4", head) + + wd := task.NewDefinition() + w, err := wf.Start(wd, map[string]interface{}{ + "Security Patch CL Numbers": []string{"1234", "5678"}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _, err = w.Run(&wf.TaskContext{Context: ctx, Logger: &testLogger{t: t}}, &verboseListener{t: t}) + if err != nil { + t.Fatal(err) + } + + // Check checkpoint branch has the expected number of submitted changes + commits := len(strings.Split(string(privRepo.runGit("log", "go1.4.2-and-go1.3.2-checkpoint", "--format=%H")), "\n")) - 1 + if commits != 3 { + t.Errorf("unexpected number of commits on checkpoint branch: got %d, want 3", commits) + } + + // Check each internal release branch has the expected cherry-picks + expected := map[string][]cherryPickedCommit{ + "internal-release-branch.go1.4.2": []cherryPickedCommit{ + { + changeID: "1234", + message: `[release-branch.go1.4] subject: 1234 + +body`, + }, + { + changeID: "5678", + message: `[release-branch.go1.4] subject: 5678 + +other body`, + }, + }, + "internal-release-branch.go1.3.2": []cherryPickedCommit{ + { + changeID: "1234", + message: `[release-branch.go1.3] subject: 1234 + +body`, + }, + { + changeID: "5678", + message: `[release-branch.go1.3] subject: 5678 + +other body`, + }, + }, + } + + for branch, commits := range privGerrit.cherryPicks { + if !slices.Equal(commits, expected[branch]) { + t.Errorf("unexpected cherry-picks on %s: got %s, want %s", branch, commits, expected[branch]) + } + } +}