blob: 9eebe6bc3118f48ef96c6bf09ceac5c50e8ee1b1 [file] [log] [blame]
// 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)
}