blob: 8ebba7c10d0b5fa6c5abead622c65e1ce982a3a1 [file] [log] [blame]
// Copyright 2023 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"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"strconv"
"strings"
"time"
"golang.org/x/build/gerrit"
"golang.org/x/build/internal/workflow"
)
// VersionTasks contains tasks related to versioning the release.
type VersionTasks struct {
Gerrit GerritClient
CloudBuild CloudBuildClient
GoProject string
UpdateProxyTestRepoTasks
}
// GetCurrentMajor returns the most recent major Go version, and the time at
// which its tag was created.
func (t *VersionTasks) GetCurrentMajor(ctx context.Context) (int, time.Time, error) {
_, currentMajor, currentMajorTag, err := t.tagInfo(ctx)
if err != nil {
return 0, time.Time{}, err
}
info, err := t.Gerrit.GetTag(ctx, t.GoProject, currentMajorTag)
if err != nil {
return 0, time.Time{}, err
}
return currentMajor, info.Created.Time(), nil
}
func (t *VersionTasks) tagInfo(ctx context.Context) (tags map[string]bool, currentMajor int, currentMajorTag string, _ error) {
tagList, err := t.Gerrit.ListTags(ctx, t.GoProject)
if err != nil {
return nil, 0, "", err
}
tags = map[string]bool{}
for _, tag := range tagList {
tags[tag] = true
}
// Find the most recently released major version.
// Going down from a high number is convenient for testing.
for currentMajor := 100; currentMajor > 0; currentMajor-- {
base := fmt.Sprintf("go1.%d", currentMajor)
// Handle either go1.20 or go1.21.0.
for _, tag := range []string{base, base + ".0"} {
if tags[tag] {
return tags, currentMajor, tag, nil
}
}
}
return nil, 0, "", fmt.Errorf("couldn't find the most recently released major version out of %d tags", len(tagList))
}
// GetNextMinorVersions returns the next minor for each of the given major series.
// It uses the same format as Go tags (for example, "go1.23.4").
func (t *VersionTasks) GetNextMinorVersions(ctx context.Context, majors []int) ([]string, error) {
var next []string
for _, major := range majors {
n, err := t.GetNextVersion(ctx, major, KindMinor)
if err != nil {
return nil, err
}
next = append(next, n)
}
return next, nil
}
// GetNextVersion returns the next for the given major series and kind of release.
// It uses the same format as Go tags (for example, "go1.23.4").
func (t *VersionTasks) GetNextVersion(ctx context.Context, major int, kind ReleaseKind) (string, error) {
tags, _, _, err := t.tagInfo(ctx)
if err != nil {
return "", err
}
findUnused := func(v string) (string, error) {
for {
if !tags[v] {
return v, nil
}
v, err = nextVersion(v)
if err != nil {
return "", err
}
}
}
switch kind {
case KindMinor:
return findUnused(fmt.Sprintf("go1.%d.1", major))
case KindBeta:
return findUnused(fmt.Sprintf("go1.%dbeta1", major))
case KindRC:
return findUnused(fmt.Sprintf("go1.%drc1", major))
case KindMajor:
return fmt.Sprintf("go1.%d.0", major), nil
default:
return "", fmt.Errorf("unknown release kind %v", kind)
}
}
// GetDevelVersion returns the current major Go 1.x version in development.
//
// This value is determined by reading the value of the Version constant in
// the internal/goversion package of the main Go repository at HEAD commit.
func (t *VersionTasks) GetDevelVersion(ctx context.Context) (int, error) {
mainBranch, err := t.Gerrit.ReadBranchHead(ctx, t.GoProject, "HEAD")
if err != nil {
return 0, err
}
tipCommit, err := t.Gerrit.ReadBranchHead(ctx, t.GoProject, mainBranch)
if err != nil {
return 0, err
}
// Fetch the goversion.go file, extract the declaration from the parsed AST.
//
// This is a pragmatic approach that relies on the trajectory of the
// internal/goversion package being predictable and unlikely to change.
// If that stops being true, this implementation is easy to re-write.
const goversionPath = "src/internal/goversion/goversion.go"
b, err := t.Gerrit.ReadFile(ctx, t.GoProject, tipCommit, goversionPath)
if errors.Is(err, gerrit.ErrResourceNotExist) {
return 0, fmt.Errorf("did not find goversion.go file (%v); possibly the internal/goversion package changed (as it's permitted to)", err)
} else if err != nil {
return 0, err
}
f, err := parser.ParseFile(token.NewFileSet(), goversionPath, b, 0)
if err != nil {
return 0, err
}
for _, d := range f.Decls {
g, ok := d.(*ast.GenDecl)
if !ok {
continue
}
for _, s := range g.Specs {
v, ok := s.(*ast.ValueSpec)
if !ok || len(v.Names) != 1 || v.Names[0].String() != "Version" || len(v.Values) != 1 {
continue
}
l, ok := v.Values[0].(*ast.BasicLit)
if !ok || l.Kind != token.INT {
continue
}
return strconv.Atoi(l.Value)
}
}
return 0, fmt.Errorf("did not find Version declaration in %s; possibly the internal/goversion package changed (as it's permitted to)", goversionPath)
}
func nextVersion(version string) (string, error) {
lastNonDigit := strings.LastIndexFunc(version, func(r rune) bool {
return r < '0' || r > '9'
})
if lastNonDigit == -1 || len(version) == lastNonDigit {
return "", fmt.Errorf("malformatted Go version %q", version)
}
n, err := strconv.Atoi(version[lastNonDigit+1:])
if err != nil {
return "", fmt.Errorf("malformatted Go version %q (%v)", version, err)
}
return fmt.Sprintf("%s%d", version[:lastNonDigit+1], n+1), nil
}
func (t *VersionTasks) GenerateVersionFile(_ *workflow.TaskContext, version string, timestamp time.Time) (string, error) {
return fmt.Sprintf("%v\ntime %v\n", version, timestamp.Format(time.RFC3339)), nil
}
// CreateAutoSubmitVersionCL mails an auto-submit change to update VERSION file on branch.
func (t *VersionTasks) CreateAutoSubmitVersionCL(ctx *workflow.TaskContext, branch, version string, reviewers []string, versionFile string) (string, error) {
return t.Gerrit.CreateAutoSubmitChange(ctx, gerrit.ChangeInput{
Project: t.GoProject,
Branch: branch,
Subject: fmt.Sprintf("[%v] %v", branch, version),
}, reviewers, map[string]string{
"VERSION": versionFile,
})
}
// AwaitCL waits for the specified CL to be submitted, and returns the new
// branch head. Callers can pass baseCommit, the current branch head, to verify
// that no CLs were submitted between when the CL was created and when it was
// merged. If changeID is blank because the intended CL was a no-op, baseCommit
// is returned immediately.
func (t *VersionTasks) AwaitCL(ctx *workflow.TaskContext, changeID, baseCommit string) (string, error) {
if changeID == "" {
ctx.Printf("No CL was necessary")
return baseCommit, nil
}
ctx.Printf("Awaiting review/submit of %v", ChangeLink(changeID))
return AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) {
return t.Gerrit.Submitted(ctx, changeID, baseCommit)
})
}
// ReadBranchHead returns the current HEAD revision of branch.
func (t *VersionTasks) ReadBranchHead(ctx *workflow.TaskContext, branch string) (string, error) {
return t.Gerrit.ReadBranchHead(ctx, t.GoProject, branch)
}
// TagRelease tags commit as version.
func (t *VersionTasks) TagRelease(ctx *workflow.TaskContext, version, commit string) error {
return t.Gerrit.Tag(ctx, t.GoProject, version, commit)
}
func (t *VersionTasks) CreateUpdateStdlibIndexCL(ctx *workflow.TaskContext, reviewers []string, version string) (string, error) {
build, err := t.CloudBuild.RunScript(ctx, "go generate ./internal/imports", "tools", []string{"internal/imports/zstdlib.go"})
if err != nil {
return "", err
}
files, err := buildToOutputs(ctx, t.CloudBuild, build)
if err != nil {
return "", err
}
changeInput := gerrit.ChangeInput{
Project: "tools",
Subject: fmt.Sprintf("internal/imports: update stdlib index for %s\n\nFor golang/go#38706.", strings.Replace(version, "go", "Go ", 1)),
Branch: "master",
}
return t.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, files)
}
// UnwaitWaitReleaseCLs changes all open Gerrit CLs with hashtag "wait-release" into "ex-wait-release".
// This is done once at the opening of a release cycle, currently via a standalone workflow.
func (t *VersionTasks) UnwaitWaitReleaseCLs(ctx *workflow.TaskContext) (result struct{}, _ error) {
waitingCLs, err := t.Gerrit.QueryChanges(ctx, "status:open hashtag:wait-release")
if err != nil {
return struct{}{}, nil
}
ctx.Printf("Processing %d open Gerrit CL with wait-release hashtag.", len(waitingCLs))
for _, cl := range waitingCLs {
const dryRun = false
if dryRun {
ctx.Printf("[dry run] Would've unwaited CL %d (%.32s…).", cl.ChangeNumber, cl.Subject)
continue
}
err := t.Gerrit.SetHashtags(ctx, cl.ID, gerrit.HashtagsInput{
Remove: []string{"wait-release"},
Add: []string{"ex-wait-release"},
})
if err != nil {
return struct{}{}, err
}
ctx.Printf("Unwaited CL %d (%.32s…).", cl.ChangeNumber, cl.Subject)
time.Sleep(3 * time.Second) // Take a moment between updating CLs to avoid a high rate of modify operations.
}
return struct{}{}, nil
}