blob: e763299da856f1bd1b3ece5a301d14f9d1a6665c [file] [log] [blame]
// Copyright 2020 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"
"slices"
"strconv"
"strings"
"time"
"golang.org/x/build/gerrit"
wf "golang.org/x/build/internal/workflow"
"golang.org/x/mod/semver"
)
// ReleaseGoplsTasks implements a new workflow definition include all the tasks
// to release a gopls.
type ReleaseGoplsTasks struct {
Gerrit GerritClient
CloudBuild CloudBuildClient
}
// NewDefinition create a new workflow definition for releasing gopls.
func (r *ReleaseGoplsTasks) NewDefinition() *wf.Definition {
wd := wf.New()
// TODO(hxjiang): provide potential release versions in the relui where the
// coordinator can choose which version to release instead of manual input.
version := wf.Param(wd, wf.ParamDef[string]{Name: "version"})
reviewers := wf.Param(wd, reviewersParam)
semversion := wf.Task1(wd, "validating input version", r.isValidVersion, version)
branchCreated := wf.Action1(wd, "creating new branch if minor release", r.createBranchIfMinor, semversion)
changeID := wf.Task2(wd, "updating branch's codereview.cfg", r.updateCodeReviewConfig, semversion, reviewers, wf.After(branchCreated))
submitted := wf.Action1(wd, "await config CL submission", r.AwaitSubmission, changeID)
changeID = wf.Task2(wd, "updating gopls' x/tools dependency", r.updateXToolsDependency, semversion, reviewers, wf.After(submitted))
_ = wf.Action1(wd, "await gopls' x/tools dependency CL submission", r.AwaitSubmission, changeID)
return wd
}
// goplsReleaseBranchName returns the branch name for given input release version.
func goplsReleaseBranchName(semv semversion) string {
return fmt.Sprintf("gopls-release-branch.%v.%v", semv.Major, semv.Minor)
}
// createBranchIfMinor create the release branch if the input version is a minor
// release.
// All patch releases under the same minor version share the same release branch.
func (r *ReleaseGoplsTasks) createBranchIfMinor(ctx *wf.TaskContext, semv semversion) error {
branch := goplsReleaseBranchName(semv)
// Require gopls release branch existence if this is a non-minor release.
if semv.Patch != 0 {
_, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch)
return err
}
// Return early if the branch already exist.
// This scenario should only occur if the initial minor release flow failed
// or was interrupted and subsequently re-triggered.
if _, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch); err == nil {
return nil
}
// Create the release branch using the revision from the head of master branch.
head, err := r.Gerrit.ReadBranchHead(ctx, "tools", "master")
if err != nil {
return err
}
ctx.Printf("Creating branch %s at revision %s.\n", branch, head)
_, err = r.Gerrit.CreateBranch(ctx, "tools", branch, gerrit.BranchInput{Revision: head})
return err
}
// openCL checks if an open CL with the given title exists in the specified
// branch.
//
// It returns an empty string if no such CL is found, otherwise it returns the
// CL's change ID.
func (r *ReleaseGoplsTasks) openCL(ctx *wf.TaskContext, branch, title string) (string, error) {
// Query for an existing pending config CL, to avoid duplication.
query := fmt.Sprintf(`message:%q status:open owner:gobot@golang.org repo:tools branch:%q -age:7d`, title, branch)
changes, err := r.Gerrit.QueryChanges(ctx, query)
if err != nil {
return "", err
}
if len(changes) == 0 {
return "", nil
}
return changes[0].ChangeID, nil
}
// updateCodeReviewConfig checks if codereview.cfg has the desired configuration.
//
// It returns the change ID required to update the config if changes are needed,
// otherwise it returns an empty string indicating no update is necessary.
func (r *ReleaseGoplsTasks) updateCodeReviewConfig(ctx *wf.TaskContext, semv semversion, reviewers []string) (string, error) {
const configFile = "codereview.cfg"
const configFmt = `issuerepo: golang/go
branch: %s
parent-branch: master
`
branch := goplsReleaseBranchName(semv)
clTitle := fmt.Sprintf("all: update %s for %s", configFile, branch)
openCL, err := r.openCL(ctx, branch, clTitle)
if err != nil {
return "", fmt.Errorf("failed to find the open CL of title %q in branch %q: %w", clTitle, branch, err)
}
if openCL != "" {
ctx.Printf("not creating CL: found existing CL %s", openCL)
return openCL, nil
}
head, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch)
if err != nil {
return "", err
}
before, err := r.Gerrit.ReadFile(ctx, "tools", head, configFile)
if err != nil && !errors.Is(err, gerrit.ErrResourceNotExist) {
return "", err
}
after := fmt.Sprintf(configFmt, branch)
// Skip CL creation as config has not changed.
if string(before) == after {
return "", nil
}
changeInput := gerrit.ChangeInput{
Project: "tools",
Subject: fmt.Sprintf("%s\n\nThis is an automated CL which updates the %s.", clTitle, configFile),
Branch: branch,
}
files := map[string]string{
configFile: string(after),
}
ctx.Printf("creating auto-submit change to %s under branch %q in x/tools repo.", configFile, branch)
return r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, files)
}
// nextPrerelease go through the tags in tools repo that matches with the given
// version and find the next pre-release version.
func (r *ReleaseGoplsTasks) nextPrerelease(ctx *wf.TaskContext, semv semversion) (int, error) {
tags, err := r.Gerrit.ListTags(ctx, "tools")
if err != nil {
return -1, fmt.Errorf("failed to list tags for tools repo: %w", err)
}
max := 0
for _, tag := range tags {
v, ok := strings.CutPrefix(tag, "gopls/")
if !ok {
continue
}
cur, ok := parseSemver(v)
if !ok {
continue
}
if cur.Major != semv.Major || cur.Minor != semv.Minor || cur.Patch != semv.Patch {
continue
}
pre, err := cur.prereleaseVersion()
if err != nil {
continue
}
if pre > max {
max = pre
}
}
return max + 1, nil
}
// updateXToolsDependency ensures gopls sub module have the correct x/tools
// version as dependency.
//
// It returns the change ID, or "" if the CL was not created.
func (r *ReleaseGoplsTasks) updateXToolsDependency(ctx *wf.TaskContext, semv semversion, reviewers []string) (string, error) {
const scriptFmt = `cp gopls/go.mod gopls/go.mod.before
cp gopls/go.sum gopls/go.sum.before
cd gopls
go mod edit -dropreplace=golang.org/x/tools
go get -u golang.org/x/tools@%s
go mod tidy -compat=1.19
`
pre, err := r.nextPrerelease(ctx, semv)
if err != nil {
return "", fmt.Errorf("failed to find the next prerelease version: %w", err)
}
branch := goplsReleaseBranchName(semv)
clTitle := fmt.Sprintf("gopls: update go.mod for v%v.%v.%v-pre.%v", semv.Major, semv.Minor, semv.Patch, pre)
openCL, err := r.openCL(ctx, branch, clTitle)
if err != nil {
return "", fmt.Errorf("failed to find the open CL of title %q in branch %q: %w", clTitle, branch, err)
}
if openCL != "" {
ctx.Printf("not creating CL: found existing CL %s", openCL)
return openCL, nil
}
outputFiles := []string{"gopls/go.mod.before", "gopls/go.mod", "gopls/go.sum.before", "gopls/go.sum"}
// TODO(hxjiang): Replacing branch with the latest non-pinned commit in the
// release branch. Rationale:
// 1. Module proxy might return an outdated commit when using the branch name
// (to be confirmed with samthanawalla@).
// 2. Pinning x/tools using the latest commit from a branch isn't idempotent.
// It's best to avoid pinning x/tools to a version that's effectively another
// pin.
build, err := r.CloudBuild.RunScript(ctx, fmt.Sprintf(scriptFmt, branch), "tools", outputFiles)
if err != nil {
return "", err
}
outputs, err := buildToOutputs(ctx, r.CloudBuild, build)
if err != nil {
return "", err
}
changedFiles := map[string]string{}
for i := 0; i < len(outputFiles); i += 2 {
before, after := outputs[outputFiles[i]], outputs[outputFiles[i+1]]
if before != after {
changedFiles[outputFiles[i+1]] = after
}
}
// Skip CL creation as nothing changed.
if len(changedFiles) == 0 {
return "", nil
}
changeInput := gerrit.ChangeInput{
Project: "tools",
Branch: branch,
Subject: fmt.Sprintf("%s\n\nThis is an automated CL which updates the go.mod go.sum.", clTitle),
}
ctx.Printf("creating auto-submit change under branch %q in x/tools repo.", branch)
return r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, changedFiles)
}
// AwaitSubmission waits for the CL with the given change ID to be submitted.
//
// The return value is the submitted commit hash, or "" if changeID is "".
func (r *ReleaseGoplsTasks) AwaitSubmission(ctx *wf.TaskContext, changeID string) error {
if changeID == "" {
ctx.Printf("not awaiting: no CL was created")
return nil
}
ctx.Printf("awaiting review/submit of %v", ChangeLink(changeID))
_, err := AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) {
return r.Gerrit.Submitted(ctx, changeID, "")
})
return err
}
func (r *ReleaseGoplsTasks) isValidVersion(ctx *wf.TaskContext, ver string) (semversion, error) {
if !semver.IsValid(ver) {
return semversion{}, fmt.Errorf("the input %q version does not follow semantic version schema", ver)
}
versions, err := r.possibleGoplsVersions(ctx)
if err != nil {
return semversion{}, fmt.Errorf("failed to get latest Gopls version tags from x/tool: %w", err)
}
if !slices.Contains(versions, ver) {
return semversion{}, fmt.Errorf("the input %q is not next version of any existing versions", ver)
}
semver, _ := parseSemver(ver)
return semver, nil
}
// semversion is a parsed semantic version.
type semversion struct {
Major, Minor, Patch int
Pre string
}
// parseSemver attempts to parse semver components out of the provided semver
// v. If v is not valid semver in canonical form, parseSemver returns false.
func parseSemver(v string) (_ semversion, ok bool) {
var parsed semversion
v, parsed.Pre, _ = strings.Cut(v, "-")
if _, err := fmt.Sscanf(v, "v%d.%d.%d", &parsed.Major, &parsed.Minor, &parsed.Patch); err == nil {
ok = true
}
return parsed, ok
}
func (s *semversion) prereleaseVersion() (int, error) {
parts := strings.Split(s.Pre, ".")
if len(parts) == 1 {
return -1, fmt.Errorf(`prerelease version does not contain any "."`)
}
if len(parts) > 2 {
return -1, fmt.Errorf(`prerelease version contains %v "."`, len(parts)-1)
}
pre, err := strconv.Atoi(parts[1])
if err != nil {
return -1, fmt.Errorf("failed to convert prerelease version to int %q: %w", pre, err)
}
return pre, nil
}
// possibleGoplsVersions identifies suitable versions for the upcoming release
// based on the current tags in the repo.
func (r *ReleaseGoplsTasks) possibleGoplsVersions(ctx *wf.TaskContext) ([]string, error) {
tags, err := r.Gerrit.ListTags(ctx, "tools")
if err != nil {
return nil, err
}
var semVersions []semversion
majorMinorPatch := map[int]map[int]map[int]bool{}
for _, tag := range tags {
v, ok := strings.CutPrefix(tag, "gopls/")
if !ok {
continue
}
if !semver.IsValid(v) {
continue
}
// Skip for pre-release versions.
if semver.Prerelease(v) != "" {
continue
}
semv, ok := parseSemver(v)
semVersions = append(semVersions, semv)
if majorMinorPatch[semv.Major] == nil {
majorMinorPatch[semv.Major] = map[int]map[int]bool{}
}
if majorMinorPatch[semv.Major][semv.Minor] == nil {
majorMinorPatch[semv.Major][semv.Minor] = map[int]bool{}
}
majorMinorPatch[semv.Major][semv.Minor][semv.Patch] = true
}
var possible []string
seen := map[string]bool{}
for _, v := range semVersions {
nextMajor := fmt.Sprintf("v%d.%d.%d", v.Major+1, 0, 0)
if _, ok := majorMinorPatch[v.Major+1]; !ok && !seen[nextMajor] {
seen[nextMajor] = true
possible = append(possible, nextMajor)
}
nextMinor := fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor+1, 0)
if _, ok := majorMinorPatch[v.Major][v.Minor+1]; !ok && !seen[nextMinor] {
seen[nextMinor] = true
possible = append(possible, nextMinor)
}
nextPatch := fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch+1)
if _, ok := majorMinorPatch[v.Major][v.Minor][v.Patch+1]; !ok && !seen[nextPatch] {
seen[nextPatch] = true
possible = append(possible, nextPatch)
}
}
semver.Sort(possible)
return possible, nil
}