| // 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" |
| "encoding/json" |
| "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/gerrit" |
| "golang.org/x/build/internal/envutil" |
| "golang.org/x/build/internal/releasetargets" |
| "golang.org/x/build/internal/task" |
| "golang.org/x/build/internal/workflow" |
| "golang.org/x/build/maintner" |
| ) |
| |
| // A Target is a release target. |
| type Target struct { |
| Name string // Target name as accepted by cmd/release. For example, "linux-amd64". |
| SkipTests bool // Skip tests. |
| } |
| |
| var releaseModes = map[string]bool{ |
| "prepare": true, |
| "release": true, |
| |
| "mail-dl-cl": true, |
| |
| "tweet-minor": true, |
| "tweet-beta": true, |
| "tweet-rc": true, |
| "tweet-major": true, |
| } |
| |
| func usage() { |
| fmt.Fprintln(os.Stderr, "usage: releasebot -mode {prepare|release|mail-dl-cl|tweet-{minor,beta,rc,major}} [-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 targets for which to skip tests (only use if sufficient testing was done elsewhere)") |
| skipTargetFlag = flag.String("skip-target", "", "space-separated list of targets to skip. This will require manual intervention to create artifacts for a target after releasing.") |
| skipAllTestsFlag = flag.Bool("skip-all-tests", false, "skip all tests for all targets (only use if tests were verified elsewhere)") |
| ) |
| |
| var ( |
| dryRun bool // only perform pre-flight checks, only log to terminal |
| ) |
| |
| 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") |
| 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 strings.HasPrefix(*modeFlag, "tweet-") { |
| kind := (*modeFlag)[len("tweet-"):] |
| postTweet(kind) |
| return |
| } else if flag.NArg() != 1 { |
| fmt.Fprintln(os.Stderr, "need to provide a release name") |
| usage() |
| } |
| releaseVersion := flag.Arg(0) |
| releaseTargets, ok := releasetargets.TargetsForVersion(releaseVersion) |
| if !ok { |
| fmt.Fprintf(os.Stderr, "could not parse release name %q\n", releaseVersion) |
| usage() |
| } |
| for _, target := range strings.Fields(*skipTestFlag) { |
| t, ok := releaseTargets[target] |
| if !ok { |
| fmt.Fprintf(os.Stderr, "target %q in -skip-test=%q is not a known target\n", target, *skipTestFlag) |
| usage() |
| } |
| t.LongTestBuilder = "" |
| t.BuildOnly = true |
| } |
| for _, target := range strings.Fields(*skipTargetFlag) { |
| if _, ok := releaseTargets[target]; !ok { |
| fmt.Fprintf(os.Stderr, "target %q in -skip-target=%q is not a known target\n", target, *skipTargetFlag) |
| usage() |
| } |
| delete(releaseTargets, target) |
| } |
| |
| 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"), |
| } |
| |
| // Validate release version types. |
| if w.BetaRelease { |
| w.ReleaseBranch = "master" |
| } else if w.RCRelease { |
| shortRel := strings.Split(w.Version, "rc")[0] |
| w.ReleaseBranch = "release-branch." + shortRel |
| } else if strings.Count(w.Version, ".") == 1 { |
| // Major release like "go1.X". |
| 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 |
| } else { |
| log.Fatalf("cannot understand version %q", w.Version) |
| } |
| |
| w.ReleaseTargets = []Target{{Name: "src"}} |
| for name, release := range releaseTargets { |
| w.ReleaseTargets = append(w.ReleaseTargets, Target{Name: name, SkipTests: release.BuildOnly || *skipAllTestsFlag}) |
| } |
| |
| // Find milestone. |
| var err error |
| w.Milestone, err = findMilestone(w.Version) |
| if err != nil { |
| log.Fatalf("cannot find the GitHub milestone for release %s: %v", 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() |
| |
| versionTasks := &task.VersionTasks{} |
| if !dryRun { |
| auth, err := loadGerritAuth() |
| if err != nil { |
| log.Fatalln("error loading Gerrit API credentials:", err) |
| } |
| versionTasks.Gerrit = &task.RealGerritClient{Client: gerrit.NewClient(gerritAPIURL, auth)} |
| } |
| |
| 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• ")) |
| var resp string |
| if _, err := fmt.Scanln(&resp); err != nil { |
| log.Fatalln(err) |
| } else if resp != "Y" && resp != "y" { |
| log.Fatalln("stopped as requested") |
| } |
| changeID, err := versionTasks.MailDLCL(&workflow.TaskContext{Context: context.Background(), Logger: log.Default()}, versions, dryRun) |
| if err != nil { |
| log.Fatalf(`task.MailDLCL(ctx, %#v, extCfg) failed: |
| |
| %v |
| |
| If it's necessary to perform it manually as a workaround, |
| consider the following steps: |
| |
| git clone https://go.googlesource.com/dl && cd dl |
| # create files displayed in the log above |
| git add . |
| git commit -m "dl: add goX.Y.Z and goX.A.B" |
| git codereview mail -trybot |
| |
| 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", task.ChangeLink(changeID)) |
| } |
| |
| // postTweet parses command-line arguments for the tweet-* modes, |
| // and runs it. |
| // kind must be one of "minor", "beta", "rc", or "major". |
| func postTweet(kind string) { |
| if flag.NArg() != 1 { |
| fmt.Fprintln(os.Stderr, "need to provide 1 release tweet JSON object") |
| usage() |
| } |
| var tweet task.ReleaseTweet |
| err := json.Unmarshal([]byte(flag.Arg(0)), &tweet) |
| if err != nil { |
| log.Fatalln("error parsing release tweet JSON object:", err) |
| } |
| |
| extCfg := task.ExternalConfig{ |
| DryRun: dryRun, |
| } |
| if !dryRun { |
| var err error |
| extCfg.TwitterAPI, err = loadTwitterAuth() |
| if err != nil { |
| log.Fatalln("error loading Twitter API credentials:", err) |
| } |
| } |
| |
| versions := []string{tweet.Version} |
| if tweet.SecondaryVersion != "" { |
| versions = append(versions, tweet.SecondaryVersion+" (secondary)") |
| } |
| fmt.Printf("About to tweet about the release of the following Go versions:\n\n\t• %s\n\n", strings.Join(versions, "\n\t• ")) |
| if tweet.Security != "" { |
| fmt.Printf("with the following security sentence (%d characters long):\n\n\t%s\n\n", len([]rune(tweet.Security)), tweet.Security) |
| } else { |
| fmt.Print("with no security fixes being mentioned,\n\n") |
| } |
| if tweet.Announcement != "" { |
| fmt.Printf("and with the following announcement URL:\n\n\t%s\n\n", tweet.Announcement) |
| } |
| fmt.Print("Ok? (Y/n) ") |
| var resp string |
| if _, err = fmt.Scanln(&resp); err != nil { |
| log.Fatalln(err) |
| } else if resp != "Y" && resp != "y" { |
| log.Fatalln("stopped as requested") |
| } |
| tweetRelease := map[string]func(*workflow.TaskContext, task.ReleaseTweet, task.ExternalConfig) (string, error){ |
| "minor": task.TweetMinorRelease, |
| "beta": task.TweetBetaRelease, |
| "rc": task.TweetRCRelease, |
| "major": task.TweetMajorRelease, |
| }[kind] |
| tweetURL, err := tweetRelease(&workflow.TaskContext{Context: context.Background(), Logger: log.Default()}, tweet, extCfg) |
| if errors.Is(err, task.ErrTweetTooLong) && len([]rune(tweet.Security)) > 120 { |
| log.Fatalf(`A tweet was not created because it's too long. |
| |
| The provided security sentence is somewhat long (%d characters), |
| so try making it shorter to avoid exceeding Twitter's limits.`, len([]rune(tweet.Security))) |
| } else if err != nil { |
| log.Fatalf(`tweetRelease(ctx, %#v, extCfg) failed: |
| |
| %v |
| |
| If it's necessary to perform it manually as a workaround, |
| consider the following options: |
| |
| • use the template displayed in the log above (if any) |
| • use the same format as the last tweet for the release |
| of the same kind |
| |
| Discuss with the secondary release coordinator as needed.`, tweet, err) |
| } |
| fmt.Printf("\nPlease check that %s looks okay\nand then refer to the playbook for the next steps.\n\n", tweetURL) |
| } |
| |
| // 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") |
| } |
| |
| // findMilestone finds the GitHub milestone corresponding to the specified Go version. |
| // If there isn't exactly one open GitHub milestone that matches, an error is returned. |
| func findMilestone(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 open, closed []*maintner.GitHubMilestone |
| goRepo.ForeachMilestone(func(m *maintner.GitHubMilestone) error { |
| if strings.ToLower(m.Title) != version { |
| return nil |
| } |
| if !m.Closed { |
| open = append(open, m) |
| } else { |
| closed = append(closed, m) |
| } |
| return nil |
| }) |
| if len(open) == 1 { |
| // Happy path: found exactly one open matching milestone. |
| return open[0], nil |
| } else if len(open) == 0 && len(closed) == 0 { |
| return nil, errors.New("no milestone found") |
| } |
| // Something's really unexpected. |
| // Include all relevant information to help the human who'll need to sort it out. |
| var buf strings.Builder |
| buf.WriteString("found duplicate or closed milestones:\n") |
| for _, m := range open { |
| fmt.Fprintf(&buf, "\t• open milestone %q (https://github.com/golang/go/milestone/%d)\n", m.Title, m.Number) |
| } |
| for _, m := range closed { |
| fmt.Fprintf(&buf, "\t• closed milestone %q (https://github.com/golang/go/milestone/%d)\n", m.Title, m.Number) |
| } |
| return nil, errors.New(buf.String()) |
| } |
| |
| 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 |
| |
| 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. |
| } |
| |
| // 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() |
| |
| // 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 { |
| 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) { |
| 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) |
| if dryRun { |
| 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)) |
| 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") |
| } |
| for _, out := range info.Outputs { |
| 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 { |
| r.run("git", "codereview", "mail", "-trybot") |
| } |
| 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) |
| |
| var wg sync.WaitGroup |
| for _, target := range w.ReleaseTargets { |
| w.releaseMu.Lock() |
| w.ReleaseInfo[target.Name] = new(ReleaseInfo) |
| w.releaseMu.Unlock() |
| |
| 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 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, "-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 || target.SkipTests { |
| 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 |
| } |
| |
| 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") |
| } |
| |
| 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 |
| } |
| |
| // splitLogMessage splits a string into n number of strings of maximum size maxStrLen. |
| // It naively attempts to split the string along the boundaries 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 |
| } |