blob: 243d527cb2984072384ad35ddd714aaca234a3c2 [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"
"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))
_ = wf.Action1(wd, "await config 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
}
// updateCodeReviewConfig ensures codereview.cfg contains the expected
// configuration.
//
// It returns the change ID, or "" if the CL was not created.
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)
// 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`, clTitle, branch)
changes, err := r.Gerrit.QueryChanges(ctx, query)
if err != nil {
return "", err
}
if len(changes) > 0 {
ctx.Printf("not creating CL: found existing CL %d", changes[0].ChangeNumber)
return changes[0].ChangeID, 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)
}
// 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
}
// 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
}