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])
+		}
+	}
+}