blob: 64e790a6a64e930f958af16d1fdbd91592166dcb [file] [log] [blame]
// Copyright 2017 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.
// Releasebot manages the process of defining,
// packaging, and publishing Go releases.
package main
import (
"bytes"
"context"
"crypto/sha1"
"crypto/sha256"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/build/buildenv"
"golang.org/x/build/internal/envutil"
"golang.org/x/build/internal/task"
"golang.org/x/build/maintner"
)
// A Target is a release target.
type Target struct {
// GoQuery is a Go version query specifying the Go versions the
// release target applies to. Empty string means all Go versions.
GoQuery string
Name string // Target name as accepted by cmd/release. For example, "linux-amd64".
TestOnly bool // Run tests only; don't produce a release artifact.
}
var releaseTargets = []Target{
// Source-only target.
{Name: "src"},
// Binary targets.
{Name: "linux-386"},
{Name: "linux-armv6l"},
{Name: "linux-amd64"},
{Name: "linux-arm64"},
{Name: "freebsd-386"},
{Name: "freebsd-amd64"},
{Name: "windows-386"},
{Name: "windows-amd64"},
{Name: "windows-arm64", GoQuery: ">= go1.17beta1"},
{Name: "darwin-amd64"},
{Name: "darwin-arm64", GoQuery: ">= go1.16beta1"},
{Name: "linux-s390x"},
{Name: "linux-ppc64le"},
// Test-only targets.
{Name: "linux-386-longtest", TestOnly: true},
{Name: "linux-amd64-longtest", TestOnly: true},
{Name: "windows-amd64-longtest", TestOnly: true},
}
var releaseModes = map[string]bool{
"prepare": true,
"release": true,
"mail-dl-cl": true,
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: releasebot -mode {prepare|release|mail-dl-cl} [-security] [-dry-run] {go1.8.5|go1.10beta2|go1.11rc1}")
flag.PrintDefaults()
os.Exit(2)
}
var (
skipTestFlag = flag.String("skip-test", "", "space-separated list of test-only targets to skip (only use if sufficient testing was done elsewhere)")
)
var (
dryRun bool // only perform pre-flight checks, only log to terminal
skipTest = make(map[string]bool) // test-only targets that should be skipped
)
func main() {
modeFlag := flag.String("mode", "", "release mode (prepare, release)")
flag.BoolVar(&dryRun, "dry-run", false, "only perform pre-flight checks, only log to terminal")
security := flag.Bool("security", false, "cut a security release from the internal Gerrit")
flag.Usage = usage
flag.Parse()
if !releaseModes[*modeFlag] {
fmt.Fprintln(os.Stderr, "need to provide a valid mode")
usage()
} else if *modeFlag == "mail-dl-cl" {
mailDLCL()
return
} else if flag.NArg() != 1 {
fmt.Fprintln(os.Stderr, "need to provide a release name")
usage()
}
releaseVersion := flag.Arg(0)
for _, target := range strings.Fields(*skipTestFlag) {
if t, ok := releaseTarget(target, releaseVersion); !ok {
fmt.Fprintf(os.Stderr, "target %q in -skip-test=%q is not a known target\n", target, *skipTestFlag)
usage()
} else if !t.TestOnly {
fmt.Fprintf(os.Stderr, "%s is not a test-only target\n", target)
usage()
}
skipTest[target] = true
}
http.DefaultTransport = newLogger(http.DefaultTransport)
buildenv.CheckUserCredentials()
checkForGitCodereview()
loadMaintner()
loadGomoteUser()
loadGithubAuth()
loadGCSAuth()
w := &Work{
Prepare: *modeFlag == "prepare",
Version: releaseVersion,
BetaRelease: strings.Contains(releaseVersion, "beta"),
RCRelease: strings.Contains(releaseVersion, "rc"),
Security: *security,
}
// Validate release version types.
if w.BetaRelease {
if w.Security {
log.Fatalf("%s is a beta version, it cannot be a security release", w.Version)
}
w.ReleaseBranch = "master"
} else if w.RCRelease {
if w.Security {
log.Fatalf("%s is a release candidate version, it cannot be a security release", w.Version)
}
shortRel := strings.Split(w.Version, "rc")[0]
w.ReleaseBranch = "release-branch." + shortRel
} else if strings.Count(w.Version, ".") == 1 {
// Major release like "go1.X".
if w.Security {
log.Fatalf("%s is a major version, it cannot be a security release", w.Version)
}
w.ReleaseBranch = "release-branch." + w.Version
} else if strings.Count(w.Version, ".") == 2 {
// Minor release or security release like "go1.X.Y".
shortRel := w.Version[:strings.LastIndex(w.Version, ".")]
w.ReleaseBranch = "release-branch." + shortRel
if w.Security {
w.ReleaseBranch += "-security"
}
} else {
log.Fatalf("cannot understand version %q", w.Version)
}
// Select release targets for this Go version.
w.ReleaseTargets = matchTargets(w.Version)
// Find milestones.
var err error
w.Milestone, err = getMilestone(w.Version)
if err != nil {
log.Fatalf("cannot find the GitHub milestone for release %s: %v", w.Version, err)
}
if !w.BetaRelease && !w.RCRelease {
nextV, err := nextVersion(w.Version)
if err != nil {
log.Fatalln("nextVersion:", err)
}
w.NextMilestone, err = getMilestone(nextV)
if err != nil {
log.Fatalf("cannot find %s, the next GitHub milestone after release %s: %v", nextV, w.Version, err)
}
}
// For major releases (go1.X), also check the "create first minor release milestone"
// step in the release process wasn't accidentally missed. See issue 44404.
if !w.BetaRelease && !w.RCRelease && strings.Count(w.Version, ".") == 1 {
firstMinor := w.Version + ".1"
if _, err := getMilestone(firstMinor); err != nil {
log.Fatalf("cannot find %s, the first minor release GitHub milestone after major release %s: %v", firstMinor, w.Version, err)
}
}
w.doRelease()
}
// mailDLCL parses command-line arguments for the mail-dl-cl mode,
// and runs it.
func mailDLCL() {
if flag.NArg() != 1 && flag.NArg() != 2 {
fmt.Fprintln(os.Stderr, "need to provide 1 or 2 versions")
usage()
}
versions := flag.Args()
fmt.Printf("About to create a golang.org/dl CL for the following Go versions:\n\n\t• %s\n\nOk? (Y/n) ", strings.Join(versions, "\n\t• "))
if dryRun {
fmt.Println("dry-run")
return
}
var response string
_, err := fmt.Scanln(&response)
if err != nil {
log.Fatalln(err)
}
if response != "Y" && response != "y" {
log.Fatalln("stopped as requested")
}
changeURL, err := task.MailDLCL(context.Background(), versions)
if err != nil {
log.Fatalf(`task.MailDLCL(ctx, %#v) failed: %v
If it's neccessary to perform it manually as a workaround,
consider the following steps:
git clone https://go.googlesource.com/dl && cd dl
go run ./internal/genv goX.Y.Z goX.A.B
git add .
git commit -m "dl: add goX.Y.Z and goX.A.B"
git codereview mail -trybot -trust
Discuss with the secondary release coordinator as needed.`, versions, err)
}
fmt.Printf("\nPlease review and submit %s\nand then refer to the playbook for the next steps.\n\n", changeURL)
}
// checkForGitCodereview exits the program if git-codereview is not installed
// in the user's path.
func checkForGitCodereview() {
cmd := exec.Command("which", "git-codereview")
if err := cmd.Run(); err != nil {
log.Fatal("could not find git-codereivew: ", cmd.Args, ": ", err, "\n\n"+
"Please install it via go install golang.org/x/review/git-codereview@latest\n"+
"to use this program.")
}
}
var gomoteUser string
func loadGomoteUser() {
tokenPath := filepath.Join(os.Getenv("HOME"), ".config/gomote")
files, _ := ioutil.ReadDir(tokenPath)
for _, file := range files {
if file.IsDir() {
continue
}
name := file.Name()
if strings.HasSuffix(name, ".token") && strings.HasPrefix(name, "user-") {
gomoteUser = strings.TrimPrefix(strings.TrimSuffix(name, ".token"), "user-")
return
}
}
log.Fatal("missing gomote token - cannot build releases.\n**FIX**: Download https://build-dot-golang-org.appspot.com/key?builder=user-YOURNAME\nand store in ~/.config/gomote/user-YOURNAME.token")
}
// getMilestone returns the GitHub milestone corresponding to the specified version,
// or an error if it cannot be found.
func getMilestone(version string) (*maintner.GitHubMilestone, error) {
// Pre-release versions of Go share the same milestone as the
// release version, so trim the pre-release suffix, if any.
if i := strings.Index(version, "beta"); i != -1 {
version = version[:i]
} else if i := strings.Index(version, "rc"); i != -1 {
version = version[:i]
}
var found *maintner.GitHubMilestone
goRepo.ForeachMilestone(func(m *maintner.GitHubMilestone) error {
if strings.ToLower(m.Title) != version {
return nil
}
found = m
return errors.New("stop iteration")
})
if found == nil {
return nil, fmt.Errorf("no milestone found for version %q", version)
}
return found, nil
}
func nextVersion(version string) (string, error) {
parts := strings.Split(version, ".")
n, err := strconv.Atoi(parts[len(parts)-1])
if err != nil {
return "", err
}
parts[len(parts)-1] = strconv.Itoa(n + 1)
return strings.Join(parts, "."), nil
}
// Work collects all the work state for managing a particular release.
// The intent is that the code could be used in a setting where one program
// is managing multiple releases, although the current releasebot command line
// only accepts a single release.
type Work struct {
logBuf *bytes.Buffer
log *log.Logger
Prepare bool // create the release commit and submit it for review
BetaRelease bool
RCRelease bool
Security bool // cut a security release from the internal Gerrit
ReleaseIssue int // Release status issue number
ReleaseBranch string // "master" for beta releases
Dir string // work directory ($HOME/go-releasebot-work/<release>)
StagingDir string // staging directory (a temporary directory inside <work>/release-staging)
Errors []string
ReleaseBinary string
ReleaseTargets []Target // Selected release targets for this release.
Version string
VersionCommit string
releaseMu sync.Mutex
ReleaseInfo map[string]*ReleaseInfo // map and info protected by releaseMu
Milestone *maintner.GitHubMilestone // Milestone for the current release.
// NextMilestone is the milestone of the next release of the same kind.
// For major releases, it's the milestone of the next major release (e.g., 1.14 → 1.15).
// For minor releases, it's the milestone of the next minor release (e.g., 1.14.1 → 1.14.2).
// For other release types, it's unset.
NextMilestone *maintner.GitHubMilestone
}
// ReleaseInfo describes a release build for a specific target.
type ReleaseInfo struct {
Outputs []*ReleaseOutput
Msg string
}
// ReleaseOutput describes a single release file.
type ReleaseOutput struct {
File string
Suffix string
Link string
Error string
}
// logError records an error.
// The error is always shown in the "PROBLEMS WITH RELEASE"
// section at the top of the status page.
// If cl is not nil, the error is also shown in that CL's summary.
func (w *Work) logError(msg string, a ...interface{}) {
w.Errors = append(w.Errors, fmt.Sprintf(msg, a...))
}
// finally should be deferred at the top of each goroutine using a Work
// (as in "defer w.finally()"). It catches and logs panics and posts
// the log.
func (w *Work) finally() {
if err := recover(); err != nil {
w.log.Printf("\n\nPANIC: %v\n\n%s", err, debug.Stack())
}
w.postSummary()
}
type runner struct {
w *Work
dir string
extraEnv []string
}
func (w *Work) runner(dir string, env ...string) *runner {
return &runner{
w: w,
dir: dir,
extraEnv: env,
}
}
// run runs the command and requires that it succeeds.
// If not, it logs the failure and aborts the work.
// It logs the command line.
func (r *runner) run(args ...string) {
out, err := r.runErr(args...)
if err != nil {
r.w.log.Printf("command failed: %s\n%s", err, out)
panic("command failed")
}
}
// runOut runs the command, requires that it succeeds,
// and returns the command's output.
// It does not log the command line except in case of failure.
// Not logging these commands avoids filling the log with
// runs of side-effect-free commands like "git cat-file commit HEAD".
func (r *runner) runOut(args ...string) []byte {
cmd := exec.Command(args[0], args[1:]...)
envutil.SetDir(cmd, r.dir)
out, err := cmd.CombinedOutput()
if err != nil {
r.w.log.Printf("$ %s\n", strings.Join(args, " "))
r.w.log.Printf("command failed: %s\n%s", err, out)
panic("command failed")
}
return out
}
// runErr runs the given command and returns the output and status (error).
// It logs the command line.
func (r *runner) runErr(args ...string) ([]byte, error) {
r.w.log.Printf("$ %s\n", strings.Join(args, " "))
cmd := exec.Command(args[0], args[1:]...)
envutil.SetDir(cmd, r.dir)
envutil.SetEnv(cmd, r.extraEnv...)
return cmd.CombinedOutput()
}
func (w *Work) doRelease() {
w.logBuf = new(bytes.Buffer)
w.log = log.New(io.MultiWriter(os.Stdout, w.logBuf), "", log.LstdFlags)
defer w.finally()
w.log.Printf("starting")
w.checkSpelling()
w.gitCheckout()
if !w.Security {
w.mustIncludeSecurityBranch()
}
// In release mode we carry on even if the tag exists, in case we
// need to resume a failed build.
if w.Prepare && w.gitTagExists() {
w.logError("%s tag already exists in Go repository!", w.Version)
w.logError("**Found errors during release. Stopping!**")
return
}
if w.BetaRelease || w.RCRelease {
// TODO: go tool api -allow_new=false
if strings.HasSuffix(w.Version, "beta1") {
w.checkBeta1ReleaseBlockers()
}
} else {
if !w.Security {
w.checkReleaseBlockers()
}
}
w.findOrCreateReleaseIssue()
if len(w.Errors) > 0 && !dryRun {
w.logError("**Found errors during release. Stopping!**")
return
}
if w.Prepare {
var changeID string
if !w.BetaRelease {
changeID = w.writeVersion()
}
// Create release archives and run all.bash tests on the builders.
w.VersionCommit = w.gitHeadCommit()
w.buildReleases()
if len(w.Errors) > 0 {
w.logError("**Found errors during release. Stopping!**")
return
}
if w.BetaRelease {
w.nextStepsBeta()
} else {
w.nextStepsPrepare(changeID)
}
} else {
if !w.BetaRelease {
w.checkVersion()
}
if len(w.Errors) > 0 {
w.logError("**Found errors during release. Stopping!**")
return
}
// Create and push the Git tag for the release, then create or reuse release archives.
// (Tests are skipped here since they ran during the prepare mode.)
w.gitTagVersion()
w.buildReleases()
if len(w.Errors) > 0 {
w.logError("**Found errors during release. Stopping!**")
return
}
switch {
case !w.BetaRelease && !w.RCRelease:
w.pushIssues()
w.closeMilestone()
case w.BetaRelease && strings.HasSuffix(w.Version, "beta1"):
w.removeOkayAfterBeta1()
}
w.nextStepsRelease()
}
}
func (w *Work) checkSpelling() {
if w.Version != strings.ToLower(w.Version) {
w.logError("release name should be lowercase: %q", w.Version)
}
if strings.Contains(w.Version, " ") {
w.logError("release name should not contain any spaces: %q", w.Version)
}
if !strings.HasPrefix(w.Version, "go") {
w.logError("release name should have 'go' prefix: %q", w.Version)
}
}
func (w *Work) checkReleaseBlockers() {
if err := goRepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
if gi.Milestone == nil || gi.Milestone.ID != w.Milestone.ID {
return nil
}
if !gi.Closed && gi.HasLabel("release-blocker") {
w.logError("open issue #%d is tagged release-blocker", gi.Number)
}
return nil
}); err != nil {
w.logError("error checking release-blockers: %v", err.Error())
return
}
}
func (w *Work) checkBeta1ReleaseBlockers() {
if err := goRepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
if gi.Milestone == nil || gi.Milestone.ID != w.Milestone.ID {
return nil
}
if !gi.Closed && gi.HasLabel("release-blocker") && !gi.HasLabel("okay-after-beta1") {
w.logError("open issue #%d is tagged release-blocker and not okay after beta1", gi.Number)
}
return nil
}); err != nil {
w.logError("error checking release-blockers: %v", err.Error())
return
}
}
func (w *Work) nextStepsPrepare(changeID string) {
if w.Security {
w.log.Printf(`
The prepare stage has completed.
Please review and submit https://team-review.git.corp.google.com/q/%s
and then run the release stage.
`, changeID)
return
}
w.log.Printf(`
The prepare stage has completed.
Please review and submit https://go-review.googlesource.com/q/%s
and then run the release stage.
`, changeID)
}
func (w *Work) nextStepsBeta() {
w.log.Printf(`
The prepare stage has completed.
Please run the release stage next.
`)
}
func (w *Work) nextStepsRelease() {
w.log.Printf(`
The release stage has completed. Thanks for riding with releasebot today!
Please refer to the playbook for the next steps.
`)
}
func (w *Work) postSummary() {
var md bytes.Buffer
if len(w.Errors) > 0 {
fmt.Fprintf(&md, "## PROBLEMS WITH RELEASE\n\n")
for _, e := range w.Errors {
fmt.Fprintf(&md, " - ")
fmt.Fprintf(&md, "%s\n", strings.Replace(strings.TrimRight(e, "\n"), "\n", "\n ", -1))
}
}
if !w.Prepare {
fmt.Fprintf(&md, "\n## Latest build: %s\n\n", mdEscape(w.Version))
w.printReleaseTable(&md)
}
fmt.Fprintf(&md, "\n## Log\n\n ")
md.WriteString(strings.Replace(w.logBuf.String(), "\n", "\n ", -1))
fmt.Fprintf(&md, "\n\n")
if len(w.Errors) > 0 {
fmt.Fprintf(&md, "There were problems with the release, see above for details.\n")
}
body := md.String()
fmt.Printf("%s", body)
// Avoid the risk of leaking sensitive test failures on security releases.
if dryRun || w.Security {
return
}
// Ensure that the entire body can be posted to the issue by splitting it into multiple
// GitHub comments if necessary. See golang.org/issue/45998.
bodyParts := splitLogMessage(body, githubCommentCharacterLimit)
for _, b := range bodyParts {
err := postGithubComment(w.ReleaseIssue, b)
if err != nil {
fmt.Printf("error posting update comment: %v\n", err)
}
}
}
func (w *Work) printReleaseTable(md *bytes.Buffer) {
// TODO: print sha256
w.releaseMu.Lock()
defer w.releaseMu.Unlock()
for _, target := range w.ReleaseTargets {
fmt.Fprintf(md, "- %s", mdEscape(target.Name))
if target.TestOnly {
fmt.Fprintf(md, " (test only)")
}
info := w.ReleaseInfo[target.Name]
if info == nil {
fmt.Fprintf(md, " - not started\n")
continue
}
if len(info.Outputs) == 0 {
fmt.Fprintf(md, " - not built")
} else if target.TestOnly && len(info.Outputs) == 1 && info.Outputs[0].Suffix == "test-only" {
fmt.Fprintf(md, " - ok")
}
for _, out := range info.Outputs {
if out.Suffix == "test-only" {
continue
} else if out.Link != "" {
fmt.Fprintf(md, " ([%s](%s))", mdEscape(out.Suffix), out.Link)
} else {
fmt.Fprintf(md, " (~~%s~~)", mdEscape(out.Suffix))
}
}
fmt.Fprintf(md, "\n")
if info.Msg != "" {
fmt.Fprintf(md, " - %s\n", strings.Replace(strings.TrimRight(info.Msg, "\n"), "\n", "\n ", -1))
}
}
}
func (w *Work) writeVersion() (changeID string) {
changeID = fmt.Sprintf("I%x", sha1.Sum([]byte(fmt.Sprintf("cmd/release-version-%s", w.Version))))
err := ioutil.WriteFile(filepath.Join(w.Dir, "gitwork", "VERSION"), []byte(w.Version), 0666)
if err != nil {
w.log.Panic(err)
}
desc := w.Version + "\n\n"
desc += "Change-Id: " + changeID + "\n"
r := w.runner(filepath.Join(w.Dir, "gitwork"))
r.run("git", "add", "VERSION")
r.run("git", "commit", "-m", desc, "VERSION")
if dryRun {
fmt.Printf("\n### VERSION commit\n\n%s\n", r.runOut("git", "show", "HEAD"))
} else if w.Security {
r.run("git", "codereview", "mail")
} else {
r.run("git", "codereview", "mail", "-trybot", "-trust")
}
return
}
// checkVersion makes sure that the version commit has been submitted.
func (w *Work) checkVersion() {
ver, err := ioutil.ReadFile(filepath.Join(w.Dir, "gitwork", "VERSION"))
if err != nil {
w.log.Panic(err)
}
if string(ver) != w.Version {
w.logError("VERSION is %q; want %q. Did you run prepare and submit the CL?", string(ver), w.Version)
}
}
func (w *Work) buildReleaseBinary() {
gopath := filepath.Join(w.Dir, "gopath")
r := w.runner(w.Dir, "GOPATH="+gopath, "GOBIN="+filepath.Join(gopath, "bin"))
r.run("go", "clean", "-modcache")
if err := os.RemoveAll(gopath); err != nil {
w.log.Panic(err)
}
if err := os.MkdirAll(gopath, 0777); err != nil {
w.log.Panic(err)
}
r.run("go", "install", "golang.org/x/build/cmd/release@latest")
w.ReleaseBinary = filepath.Join(gopath, "bin/release")
}
func (w *Work) buildReleases() {
w.buildReleaseBinary()
if err := os.MkdirAll(filepath.Join(w.Dir, "release", w.VersionCommit), 0777); err != nil {
w.log.Panic(err)
}
if err := os.MkdirAll(filepath.Join(w.Dir, "release-staging"), 0777); err != nil {
w.log.Panic(err)
}
stagingDir, err := ioutil.TempDir(filepath.Join(w.Dir, "release-staging"), w.VersionCommit+"_")
if err != nil {
w.log.Panic(err)
}
w.StagingDir = stagingDir
w.ReleaseInfo = make(map[string]*ReleaseInfo)
if w.Security {
fmt.Printf(`
Please download
https://team.git.corp.google.com/golang/go-private/+archive/%s.tar.gz
to %s and press enter.
`, w.VersionCommit, filepath.Join(w.Dir, w.VersionCommit+".tar.gz"))
_, err := fmt.Scanln()
if err != nil {
w.log.Panic(err)
}
}
var wg sync.WaitGroup
for _, target := range w.ReleaseTargets {
w.releaseMu.Lock()
w.ReleaseInfo[target.Name] = new(ReleaseInfo)
w.releaseMu.Unlock()
if target.TestOnly && skipTest[target.Name] {
w.log.Printf("skipping test-only target %s because of -skip-test=%q flag", target.Name, *skipTestFlag)
w.releaseMu.Lock()
w.ReleaseInfo[target.Name].Msg = fmt.Sprintf("skipped because of -skip-test=%q flag", *skipTestFlag)
w.releaseMu.Unlock()
continue
}
wg.Add(1)
target := target
go func() {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
stk := strings.TrimSpace(string(debug.Stack()))
msg := fmt.Sprintf("PANIC: %v\n\n %s\n", mdEscape(fmt.Sprint(err)), strings.Replace(stk, "\n", "\n ", -1))
w.logError(msg)
w.log.Printf("\n\nBuilding %s: PANIC: %v\n\n%s", target.Name, err, debug.Stack())
w.releaseMu.Lock()
w.ReleaseInfo[target.Name].Msg = msg
w.releaseMu.Unlock()
}
}()
w.buildRelease(target)
}()
}
wg.Wait()
// Check for release errors and stop if any.
w.releaseMu.Lock()
for _, target := range w.ReleaseTargets {
for _, out := range w.ReleaseInfo[target.Name].Outputs {
if out.Error != "" || len(w.Errors) > 0 {
w.logError("RELEASE BUILD FAILED\n")
w.releaseMu.Unlock()
return
}
}
}
w.releaseMu.Unlock()
}
// buildRelease builds the release packaging for a given target. Because the
// "release" program can be flaky, it tries multiple times before stopping.
// The release files are first written to a staging directory specified in w.StagingDir
// (a temporary directory inside $HOME/go-releasebot-work/go1.2.3/release-staging),
// then after the all.bash tests complete successfully (or get skipped),
// they get moved to the final release directory
// ($HOME/go-releasebot-work/go1.2.3/release/COMMIT_HASH).
//
// If files for the current version commit are already present in the release directory,
// they are reused instead of being rebuilt. In release mode, buildRelease then uploads
// the release packaging to the gs://golang-release-staging bucket, along with files
// containing the SHA256 hash of the releases, for eventual use by the download page.
func (w *Work) buildRelease(target Target) {
log.Printf("BUILDRELEASE %s %s\n", w.Version, target.Name)
defer log.Printf("DONE BUILDRELEASE %s %s\n", w.Version, target.Name)
releaseDir := filepath.Join(w.Dir, "release", w.VersionCommit)
prefix := fmt.Sprintf("%s.%s.", w.Version, target.Name)
var files []string
switch {
case target.TestOnly:
files = []string{prefix + "test-only"}
case strings.HasPrefix(target.Name, "windows-"):
files = []string{prefix + "zip", prefix + "msi"}
default:
files = []string{prefix + "tar.gz"}
}
var outs []*ReleaseOutput
haveFiles := true
for _, file := range files {
out := &ReleaseOutput{
File: file,
Suffix: strings.TrimPrefix(file, prefix),
}
outs = append(outs, out)
_, err := os.Stat(filepath.Join(releaseDir, file))
if err != nil {
haveFiles = false
}
}
w.releaseMu.Lock()
w.ReleaseInfo[target.Name].Outputs = outs
w.releaseMu.Unlock()
if haveFiles {
w.log.Printf("release -target=%q: already have %v; not rebuilding files", target.Name, files)
} else {
failures := 0
for {
args := []string{w.ReleaseBinary, "-target", target.Name, "-user", gomoteUser,
"-version", w.Version, "-staging_dir", w.StagingDir}
if w.Security {
args = append(args, "-tarball", filepath.Join(w.Dir, w.VersionCommit+".tar.gz"))
} else {
args = append(args, "-rev", w.VersionCommit)
}
// The prepare step will run the tests on a commit that has the same
// tree (but maybe different message) as the one that the release
// step will process, so we can skip tests the second time.
if !w.Prepare {
args = append(args, "-skip_tests")
}
releaseOutput, releaseError := w.runner(releaseDir, "GOPATH="+filepath.Join(w.Dir, "gopath")).runErr(args...)
// Exit code from release binary is apparently unreliable.
// Look to see if the files we expected were created instead.
failed := false
w.releaseMu.Lock()
for _, out := range outs {
if _, err := os.Stat(filepath.Join(releaseDir, out.File)); err != nil {
failed = true
}
}
w.releaseMu.Unlock()
if !failed {
w.log.Printf("release -target=%q: build succeeded (after %d retries)\n", target.Name, failures)
break
}
w.log.Printf("release -target=%q did not produce expected output files %v:\nerror from cmd/release binary = %v\noutput from cmd/release binary:\n%s", target.Name, files, releaseError, releaseOutput)
if failures++; failures >= 3 {
w.log.Printf("release -target=%q: too many failed attempts, stopping\n", target.Name)
for _, out := range outs {
w.releaseMu.Lock()
out.Error = fmt.Sprintf("release -target=%q: build failed", target.Name)
w.releaseMu.Unlock()
}
return
}
w.log.Printf("release -target=%q: waiting a bit and trying again\n", target.Name)
time.Sleep(1 * time.Minute)
}
}
if dryRun || w.Prepare {
return
}
if target.TestOnly {
// This was a test-only target, nothing to upload.
return
}
for _, out := range outs {
if err := w.uploadStagingRelease(target, out); err != nil {
w.log.Printf("error uploading release %s to staging bucket: %s", target.Name, err)
w.releaseMu.Lock()
out.Error = err.Error()
w.releaseMu.Unlock()
}
}
}
// uploadStagingRelease uploads target to the release staging bucket.
// If successful, it records the corresponding URL in out.Link.
// In addition to uploading target, it creates and uploads a file
// named "<target>.sha256" containing the hex sha256 hash
// of the target file. This is needed for the release signing process
// and also displayed on the eventual download page.
func (w *Work) uploadStagingRelease(target Target, out *ReleaseOutput) error {
if dryRun {
return errors.New("attempted write operation in dry-run mode")
} else if target.TestOnly {
return errors.New("attempted to upload a test-only target")
}
src := filepath.Join(w.Dir, "release", w.VersionCommit, out.File)
h := sha256.New()
f, err := os.Open(src)
if err != nil {
return err
}
_, err = io.Copy(h, f)
f.Close()
if err != nil {
return err
}
if err := ioutil.WriteFile(src+".sha256", []byte(fmt.Sprintf("%x", h.Sum(nil))), 0666); err != nil {
return err
}
dst := w.Version + "/" + out.File
if err := gcsUpload(src, dst); err != nil {
return err
}
if err := gcsUpload(src+".sha256", dst+".sha256"); err != nil {
return err
}
w.releaseMu.Lock()
out.Link = "https://" + releaseBucket + ".storage.googleapis.com/" + dst
w.releaseMu.Unlock()
return nil
}
// mustIncludeSecurityBranch remotely checks if there is an associated release branch
// for the current release. If one exists, it ensures that the HEAD commit in the latest
// security release branch exists within the current release branch. If the latest security
// branch has changes which have not been merged into the proposed release, it will exit
// fatally. If an asssociated security release branch does not exist, the function will
// return without doing the check. It assumes that if the security branch doesn't exist,
// it's because it was already merged everywhere and deleted.
func (w *Work) mustIncludeSecurityBranch() {
securityReleaseBranch := fmt.Sprintf("%s-security", w.ReleaseBranch)
sha, ok := w.gitRemoteBranchCommit(privateGoRepoURL, securityReleaseBranch)
if !ok {
w.log.Printf("an associated security release branch %q does not exist; assuming it has been merged and deleted, so proceeding as usual", securityReleaseBranch)
return
}
if !w.gitCommitExistsInBranch(sha) {
log.Fatalf("release branch does not contain security release HEAD commit %q; aborting", sha)
}
}
// releaseTarget returns a release target with the specified name
// for the specified Go version.
func releaseTarget(name, goVer string) (_ Target, ok bool) {
for _, t := range releaseTargets {
if !match(t.GoQuery, goVer) {
continue
}
if t.Name == name {
return t, true
}
}
return Target{}, false
}
// matchTargets selects release targets that have a matching
// GoQuery value for the specified Go version.
func matchTargets(goVer string) (matched []Target) {
for _, t := range releaseTargets {
if !match(t.GoQuery, goVer) {
continue
}
matched = append(matched, t)
}
return matched
}
// match reports whether the Go version goVer matches the provided version query.
// The empty query matches all Go versions.
// match panics if given a query that it doesn't support.
func match(query, goVer string) bool {
// TODO(golang.org/issue/40558): This should help inform the API for a Go version parser.
switch query {
case "": // A special case to make the zero Target.GoQuery value useful.
return true
case ">= go1.17beta1":
return !strings.HasPrefix(goVer, "go1.16") && !strings.HasPrefix(goVer, "go1.15")
case ">= go1.16beta1":
return !strings.HasPrefix(goVer, "go1.15")
default:
panic(fmt.Errorf("match: query %q is not supported", query))
}
}
// splitLogMessage splits a string into n number of strings of maximum size maxStrLen.
// It naively attempts to split the string along the bounderies of new line characters in order
// to make each individual string as readable as possible.
func splitLogMessage(s string, maxStrLen int) []string {
sl := []string{}
for len(s) > maxStrLen {
end := strings.LastIndex(s[:maxStrLen], "\n")
if end == -1 {
end = maxStrLen
}
sl = append(sl, s[:end])
if string(s[end]) == "\n" {
s = s[end+1:]
} else {
s = s[end:]
}
}
sl = append(sl, s)
return sl
}