blob: a8611e6f3e832e5fb472f775b3c8db786673a204 [file] [log] [blame]
package try
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/urfave/cli/v2"
"golang.org/x/oauth2/google"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/gerrit"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/gitiles"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/supported_branches"
"go.skia.org/infra/go/util"
"go.skia.org/infra/task_scheduler/go/specs"
)
const (
chromeBranchTmpl = "chrome/m%s"
flutterBranchTmpl = "flutter/%s.%s"
flagReviewers = "reviewer"
gerritProject = "skia"
milestoneFile = "include/core/SkMilestone.h"
milestoneTmpl = "#define SK_MILESTONE %s"
supportedChromeBranches = 4
supportedFlutterBranches = 2
updateMilestoneCommitMsgTmpl = "Update Skia milestone to %d"
jobsJSONFile = "infra/bots/jobs.json"
tasksJSONFile = "infra/bots/tasks.json"
cqJSONFile = "infra/skcq.json"
)
var (
chromeBranchMilestoneRegex = regexp.MustCompile(fmt.Sprintf(chromeBranchTmpl, `(\d+)`))
flutterBranchVersionRegex = regexp.MustCompile(fmt.Sprintf(flutterBranchTmpl, `(\d+)`, `(\d+)`))
milestoneRegex = regexp.MustCompile(fmt.Sprintf(milestoneTmpl, `(\d+)`))
excludeTrybotsOnReleaseBranches = []*regexp.Regexp{
regexp.MustCompile(".*CanvasKit.*"),
regexp.MustCompile(".*PathKit.*"),
}
jobsJSONReplaceRegex = regexp.MustCompile(`(?m)\{\n "name": "(\S+)",\n "cq_config": null\n }`)
jobsJSONReplaceContents = []byte(`{"name": "$1"}`)
)
// Command returns a cli.Command instance which represents the "release-branch"
// command.
func Command() *cli.Command {
return &cli.Command{
Name: "release-branch",
Usage: "release-branch [options] <newly-created branch name>",
Description: "Perform the necessary updates after creating a new release branch. The new branch must already exist.",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: flagReviewers,
Value: &cli.StringSlice{},
Usage: "Reviewers for the uploaded CLs. If not provided, the CLs are not sent for review.",
},
},
Action: func(ctx *cli.Context) error {
args := ctx.Args().Slice()
if len(args) != 1 {
return skerr.Fmt("Exactly one positional argument is expected.")
}
return releaseBranch(ctx.Context, args[0], ctx.StringSlice(flagReviewers))
},
}
}
// releaseBranch performs the actions necessary to create a new Skia release
// branch.
func releaseBranch(ctx context.Context, newBranch string, reviewers []string) error {
// Setup.
ts, err := google.DefaultTokenSource(ctx, auth.ScopeGerrit)
if err != nil {
return skerr.Wrap(err)
}
client := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
repo := gitiles.NewRepo(common.REPO_SKIA, client)
g, err := gerrit.NewGerrit(gerrit.GerritSkiaURL, client)
if err != nil {
return skerr.Wrap(err)
}
// Derive the newly-expired branch name and, in the case of Chrome branches,
// the milestone number, from the new branch name.
var removeBranch string
if m := chromeBranchMilestoneRegex.FindStringSubmatch(newBranch); len(m) == 2 {
// This is a Chrome branch. Parse the current milestone from the branch
// name.
currentMilestone, err := strconv.Atoi(m[1])
if err != nil {
return skerr.Wrap(err)
}
removeBranch = fmt.Sprintf(chromeBranchTmpl, strconv.Itoa(currentMilestone-supportedChromeBranches))
if err := updateMilestone(ctx, g, repo, currentMilestone, reviewers); err != nil {
return skerr.Wrap(err)
}
} else if flutterVersion, err := parseFlutterVersion(newBranch); err == nil {
// This is a Flutter branch. Find all Flutter branches and determine
// which one has scrolled out of the support window.
branches, err := repo.Branches(ctx)
if err != nil {
return skerr.Wrap(err)
}
flutterVersions := make([]semanticVersion, 0, len(branches))
for _, branch := range branches {
if version, err := parseFlutterVersion(branch.Name); err == nil {
flutterVersions = append(flutterVersions, version)
}
}
sort.Sort(sort.Reverse(semanticVersionSlice(flutterVersions)))
flutterVersionIdx := -1
for idx, version := range flutterVersions {
if version == flutterVersion {
flutterVersionIdx = idx
break
}
}
if flutterVersionIdx < 0 {
return skerr.Fmt("Unable to find %s in available branches; has it been created?", newBranch)
}
deleteVersionIdx := flutterVersionIdx + supportedFlutterBranches
if deleteVersionIdx < len(flutterVersions) {
deleteVersion := flutterVersions[deleteVersionIdx]
removeBranch = fmt.Sprintf(flutterBranchTmpl, strconv.Itoa(deleteVersion.major), strconv.Itoa(deleteVersion.minor))
}
} else {
return skerr.Fmt("%q is not a recognized branch name format for Chrome or Flutter; wanted %q or %q", newBranch, chromeBranchTmpl, flutterBranchTmpl)
}
fmt.Println("Creating CL to update supported branches...")
if err := updateSupportedBranches(ctx, g, repo, removeBranch, newBranch, reviewers); err != nil {
return skerr.Wrap(err)
}
fmt.Println(fmt.Sprintf("Creating CL to filter out unsupported CQ try jobs on %s...", newBranch))
if err := updateTryjobs(ctx, g, repo, newBranch, reviewers); err != nil {
return skerr.Wrap(err)
}
if removeBranch != "" {
fmt.Println(fmt.Sprintf("Creating CL to remove CQ on %s", removeBranch))
if err := removeCQ(ctx, g, repo, removeBranch, reviewers); err != nil {
return skerr.Wrap(err)
}
}
return nil
}
// getCurrentMilestone retrieves the current milestone value.
func getCurrentMilestone(ctx context.Context, repo gitiles.GitilesRepo) (int, string, error) {
contents, err := repo.ReadFileAtRef(ctx, milestoneFile, git.MainBranch)
if err != nil {
return 0, "", skerr.Wrap(err)
}
match := milestoneRegex.FindAllStringSubmatch(string(contents), 1)
if len(match) != 1 {
return 0, "", skerr.Fmt("Unable to parse milestone number from: %s", string(contents))
}
if len(match[0]) != 2 {
return 0, "", skerr.Fmt("Unable to parse milestone number from: %s", string(contents))
}
milestone, err := strconv.Atoi(match[0][1])
if err != nil {
return 0, "", skerr.Wrap(err)
}
return milestone, string(contents), nil
}
// updateMilestone creates a CL to update the Skia milestone.
func updateMilestone(ctx context.Context, g gerrit.GerritInterface, repo *gitiles.Repo, currentMilestone int, reviewers []string) error {
newMilestone := currentMilestone + 1
// Retrieve the current milestone.
haveMilestone, oldMilestoneContents, err := getCurrentMilestone(ctx, repo)
if err != nil {
return skerr.Wrap(err)
}
if haveMilestone == newMilestone {
fmt.Println(fmt.Sprintf("Milestone is up to date at %d; not updating.", newMilestone))
return nil
}
fmt.Println(fmt.Sprintf("Creating CL to update milestone to %d...", newMilestone))
commitMsg := fmt.Sprintf(updateMilestoneCommitMsgTmpl, newMilestone)
newContents := milestoneRegex.ReplaceAllString(oldMilestoneContents, fmt.Sprintf(milestoneTmpl, strconv.Itoa(newMilestone)))
changes := map[string]string{
milestoneFile: newContents,
}
baseCommit, err := repo.ResolveRef(ctx, git.MainBranch)
if err != nil {
return skerr.Wrap(err)
}
ci, err := gerrit.CreateCLWithChanges(ctx, g, gerritProject, git.MainBranch, commitMsg, baseCommit, changes, reviewers)
if ci != nil {
fmt.Println(fmt.Sprintf("Uploaded change %s", g.Url(ci.Issue)))
}
return skerr.Wrap(err)
}
// updateSupportedBranches updates the infra/config branch to edit the supported
// branches and commit queue config to add the new branch and remove the old.
func updateSupportedBranches(ctx context.Context, g gerrit.GerritInterface, repo *gitiles.Repo, removeBranch string, newBranch string, reviewers []string) error {
owner, err := g.GetUserEmail(ctx)
if err != nil {
return skerr.Wrap(err)
}
newRef := git.FullyQualifiedBranchName(newBranch)
// Setup.
baseCommitInfo, err := repo.Details(ctx, supported_branches.SUPPORTED_BRANCHES_REF)
if err != nil {
return skerr.Wrap(err)
}
baseCommit := baseCommitInfo.Hash
// Download and modify the supported-branches.json file.
branchesContents, err := repo.ReadFileAtRef(ctx, supported_branches.SUPPORTED_BRANCHES_FILE, baseCommit)
if err != nil {
return skerr.Wrap(err)
}
sbc, err := supported_branches.DecodeConfig(bytes.NewReader(branchesContents))
if err != nil {
return skerr.Wrap(err)
}
deleteRef := ""
if removeBranch != "" {
deleteRef = git.FullyQualifiedBranchName(removeBranch)
}
foundNewRef := false
deletedRef := false
newBranches := make([]*supported_branches.SupportedBranch, 0, len(sbc.Branches)+1)
for _, sb := range sbc.Branches {
if sb.Ref != deleteRef {
newBranches = append(newBranches, sb)
} else {
deletedRef = true
}
if sb.Ref == newRef {
foundNewRef = true
}
}
if foundNewRef {
_, _ = fmt.Fprintf(os.Stderr, "Already have %s in %s; not adding a duplicate.\n", newRef, supported_branches.SUPPORTED_BRANCHES_FILE)
} else {
newBranches = append(newBranches, &supported_branches.SupportedBranch{
Ref: newRef,
Owner: owner,
})
}
if !deletedRef {
_, _ = fmt.Fprintf(os.Stderr, "%s not found in %s; not removing.\n", deleteRef, supported_branches.SUPPORTED_BRANCHES_FILE)
}
if !deletedRef && foundNewRef {
// We didn't change anything, so don't upload a CL.
return nil
}
sbc.Branches = newBranches
buf := bytes.Buffer{}
if err := supported_branches.EncodeConfig(&buf, sbc); err != nil {
return skerr.Wrap(err)
}
// Create the Gerrit CL.
commitMsg := fmt.Sprintf("Add supported branch %s, remove %s", newBranch, removeBranch)
repoSplit := strings.Split(repo.URL(), "/")
project := strings.TrimSuffix(repoSplit[len(repoSplit)-1], ".git")
changes := map[string]string{
supported_branches.SUPPORTED_BRANCHES_FILE: buf.String(),
}
ci, err := gerrit.CreateCLWithChanges(ctx, g, project, supported_branches.SUPPORTED_BRANCHES_REF, commitMsg, baseCommit, changes, reviewers)
if ci != nil {
fmt.Println(fmt.Sprintf("Uploaded change %s", g.Url(ci.Issue)))
}
return skerr.Wrap(err)
}
func updateTryjobs(ctx context.Context, g gerrit.GerritInterface, repo *gitiles.Repo, newBranch string, reviewers []string) error {
// Setup.
newRef := git.FullyQualifiedBranchName(newBranch)
baseCommitInfo, err := repo.Details(ctx, newRef)
if err != nil {
return skerr.Wrap(err)
}
baseCommit := baseCommitInfo.Hash
tmp, err := ioutil.TempDir("", "")
if err != nil {
return skerr.Wrap(err)
}
defer util.RemoveAll(tmp)
co, err := git.NewCheckout(ctx, repo.URL(), tmp)
if err != nil {
return skerr.Wrap(err)
}
if err := co.CleanupBranch(ctx, newBranch); err != nil {
return skerr.Wrap(err)
}
// Download and modify the jobs.json file.
oldJobsContents, err := repo.ReadFileAtRef(ctx, jobsJSONFile, baseCommit)
if err != nil {
return skerr.Wrap(err)
}
var jobs []struct {
Name string `json:"name"`
CqConfig *specs.CommitQueueJobConfig `json:"cq_config"`
}
if err := json.Unmarshal(oldJobsContents, &jobs); err != nil {
return skerr.Wrapf(err, "failed to decode jobs.json")
}
for _, job := range jobs {
for _, re := range excludeTrybotsOnReleaseBranches {
if re.MatchString(job.Name) {
job.CqConfig = nil
break
}
}
}
newJobsContents, err := json.MarshalIndent(jobs, "", " ")
if err != nil {
return skerr.Wrapf(err, "failed to encode jobs.json")
}
// Replace instances of `"cq_config": null`; these are cluttery and
// unnecessary, but we can't use omitempty because that causes the Marshaler
// to also omit `"cq_config": {}` which indicates that a job *should* be on
// the CQ. Also attempt to match the whitespace of the original files, to
// help prevent conflicts during cherry-picks.
newJobsContents = jobsJSONReplaceRegex.ReplaceAll(newJobsContents, jobsJSONReplaceContents)
if err := ioutil.WriteFile(filepath.Join(co.Dir(), jobsJSONFile), newJobsContents, os.ModePerm); err != nil {
return skerr.Wrapf(err, "failed to write %s", jobsJSONFile)
}
// Regenerate tasks.json.
if _, err := exec.RunCwd(ctx, co.Dir(), "go", "run", "./infra/bots/gen_tasks.go"); err != nil {
return skerr.Wrapf(err, "failed to regenerate tasks.json")
}
// Create the Gerrit CL.
commitMsg := fmt.Sprintf("Filter unsupported CQ try jobs on %s", newBranch)
repoSplit := strings.Split(repo.URL(), "/")
project := strings.TrimSuffix(repoSplit[len(repoSplit)-1], ".git")
ci, err := gerrit.CreateCLFromLocalDiffs(ctx, g, project, newBranch, commitMsg, reviewers, co)
if err != nil {
return skerr.Wrap(err)
}
fmt.Println(fmt.Sprintf("Uploaded change %s", g.Url(ci.Issue)))
return nil
}
func removeCQ(ctx context.Context, g gerrit.GerritInterface, repo *gitiles.Repo, oldBranch string, reviewers []string) error {
// Setup.
oldRef := git.FullyQualifiedBranchName(oldBranch)
baseCommitInfo, err := repo.Details(ctx, oldRef)
if err != nil {
return skerr.Wrap(err)
}
baseCommit := baseCommitInfo.Hash
// Create the Gerrit CL.
if _, err := repo.ReadFileAtRef(ctx, cqJSONFile, oldRef); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Attempt to read %s on %s got error: %s; not removing CQ.\n", oldBranch, cqJSONFile, err)
return nil
}
commitMsg := fmt.Sprintf("Remove CQ for unsupported branch %s", oldBranch)
repoSplit := strings.Split(repo.URL(), "/")
project := strings.TrimSuffix(repoSplit[len(repoSplit)-1], ".git")
changes := map[string]string{
cqJSONFile: "",
}
ci, err := gerrit.CreateCLWithChanges(ctx, g, project, oldRef, commitMsg, baseCommit, changes, reviewers)
if ci != nil {
fmt.Println(fmt.Sprintf("Uploaded change %s", g.Url(ci.Issue)))
}
return skerr.Wrap(err)
}
func parseFlutterVersion(branchName string) (semanticVersion, error) {
var rv semanticVersion
m := flutterBranchVersionRegex.FindStringSubmatch(branchName)
if len(m) != 3 {
return rv, skerr.Fmt("invalid branch name %q; expected format %q", branchName, flutterBranchTmpl)
}
var err error
rv.major, err = strconv.Atoi(m[1])
if err != nil {
return rv, skerr.Wrap(err)
}
rv.minor, err = strconv.Atoi(m[2])
if err != nil {
return rv, skerr.Wrap(err)
}
return rv, nil
}
type semanticVersion struct {
major int
minor int
}
type semanticVersionSlice []semanticVersion
func (s semanticVersionSlice) Len() int {
return len(s)
}
func (s semanticVersionSlice) Less(i, j int) bool {
if s[i].major == s[j].major {
return s[i].minor < s[j].minor
}
return s[i].major < s[j].major
}
func (s semanticVersionSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}