blob: 3b33b26e69ad355c0e2930813f3830adcb329c02 [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.
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"golang.org/x/build/gerrit"
)
// gitCheckout sets up a fresh git checkout in which to work,
// in $HOME/go-releasebot-work/<release>/gitwork
// (where <release> is a string like go1.8.5).
// The first time it is run for a particular release,
// gitCheckout also creates a clean checkout in
// $HOME/go-releasebot-work/<release>/gitmirror,
// to use as an object cache to speed future checkouts.
// On return, w.runDir has been set to gitwork/src,
// to allow commands like "./make.bash".
func (w *Work) gitCheckout() {
shortRel := strings.ToLower(w.Milestone.GetTitle())
shortRel = shortRel[:strings.LastIndex(shortRel, ".")]
w.ReleaseBranch = "release-branch." + shortRel
//TODO: move to go-releasebot-work
w.Dir = filepath.Join(os.Getenv("HOME"), "go-releasebot-work/"+strings.ToLower(w.Milestone.GetTitle()))
w.log.Printf("working in %s\n", w.Dir)
if err := os.MkdirAll(w.Dir, 0777); err != nil {
w.log.Panic(err)
}
// Check out a local mirror to work-mirror, to speed future checkouts for this point release.
mirror := filepath.Join(w.Dir, "gitmirror")
if _, err := os.Stat(mirror); err != nil {
w.run("git", "clone", "https://go.googlesource.com/go", mirror)
w.runDir = mirror
w.run("git", "config", "gc.auto", "0") // don't throw away refs we fetch
} else {
w.runDir = mirror
w.run("git", "fetch", "origin", "master")
}
w.run("git", "fetch", "origin", w.ReleaseBranch)
// Clone real Gerrit, but using local mirror for most objects.
gitDir := filepath.Join(w.Dir, "gitwork")
if err := os.RemoveAll(gitDir); err != nil {
w.log.Panic(err)
}
w.run("git", "clone", "--reference", mirror, "-b", w.ReleaseBranch, "https://go.googlesource.com/go", gitDir)
w.runDir = gitDir
w.run("git", "change", "relwork")
w.run("git", "config", "gc.auto", "0") // don't throw away refs we fetch
w.runDir = filepath.Join(gitDir, "src")
version := strings.ToLower(w.Milestone.GetTitle())
_, err := w.runErr("git", "rev-parse", version)
if err == nil && !w.CloseMilestone {
w.logError(nil, fmt.Sprintf("%s tag already exists in Go repository!", version))
w.log.Panic("already released")
}
if err != nil && w.CloseMilestone {
w.log.Panic("not yet released")
}
}
// gitFetchCLs fetches into gitwork the commits of each CL in w.CLs.
// It also initializes cl.Order to a numerically increasing ordering that
// respects git commit sequencing. CLs already merged into the master branch
// are ordered before CLs that are pending or found on other branches.
func (w *Work) gitFetchCLs() {
args := []string{"git", "fetch", "origin"}
args = append(args, "master:gerrit/master", w.ReleaseBranch+":gerrit/"+w.ReleaseBranch)
for _, cl := range w.CLs {
if cl.Ref != "" {
args = append(args, cl.Ref+":gerrit/"+cl.Ref)
}
}
w.runOut(args...)
order := make(map[string]int)
for _, cl := range w.CLs {
order[cl.Commit] = -1
}
next := 0
for _, ref := range args[3:] {
ref := ref[strings.Index(ref, ":")+1:]
lines := strings.Split(string(w.runOut("git", "log", "--pretty=format:%H", ref)), "\n")
n := 0
for _, line := range lines {
if order[line] > 0 {
break
}
if order[line] == -1 {
n++
}
}
n += next
next = n
for _, line := range lines {
if order[line] > 0 {
break
}
if order[line] == -1 {
order[line] = n
n--
}
}
}
for _, cl := range w.CLs {
cl.Order = order[cl.Commit]
}
}
// orderCLs decides the order in which to apply CLs to the release branch.
// The order chosen is the original commit order recorded by gitFetchCLs,
// with prerequisites specified in the issue directives pulled in eagerly.
//
// For example, suppose we want to pick CLs 1 2 3 4 5 from master
// along with pending CL 6, which is a replacement for a CL from master
// that happened between 2 and 3. The normal order we'd choose would
// be 1 2 3 4 5 6, but if the issue directive OKing CL 3 says:
//
// CL 3 OK for Go 1.9.2 (after CL 6).
//
// then CL 6 will be inserted ahead of where 3 would normally be chosen,
// leading to 1 2 6 3 4 5.
//
// An alternative would be to delay 3 until 6 had come up normally,
// producing 1 2 4 5 6 3, but in general these new CLs are rewrites to
// replace older CLs, so sliding individual new CLs earlier and therefore
// preserving the original master order (in this case, keeping 3 before 4
// without having to say so explicitly) typically works better.
func (w *Work) orderCLs() {
cls := w.CLs
sort.Slice(cls, func(i, j int) bool { return cls[i].Order < cls[j].Order })
var order []*CL
walking := make(map[*CL]bool)
walked := make(map[*CL]bool)
clByNum := map[int]*CL{}
for _, cl := range cls {
clByNum[cl.Num] = cl
}
var walk func(cl *CL)
walk = func(cl *CL) {
if walked[cl] {
return
}
if walking[cl] {
w.log.Panic("CL cycle")
}
walking[cl] = true
for _, prereq := range cl.Prereq {
if clByNum[prereq] == nil {
w.log.Panicf("CL %d has prereq non-approved CL %d", cl.Num, prereq)
}
walk(clByNum[prereq])
}
order = append(order, cl)
walked[cl] = true
}
for _, cl := range cls {
walk(cl)
}
if len(order) != len(cls) {
w.log.Panic("dropped CLs during ordering")
}
copy(cls, order)
w.CLs = cls
}
// cherryPickCLs applies the CLs, in the order chosen by orderCLs, to the release branch.
// If a cherry-pick fails, the error is recorded but it does not stop the overall process,
// so that (especially early in the process), a cherry-pick failure in one directory does not
// keep us from finding out whether cherry-picks in other directories work.
// After each chery-pick, cherryPickCLs checks to see if there is a corresponding Gerrit CL
// already, and if it has exactly the same parent and file content, cherryPickCLs reuses
// that existing CL, to reduce the number of uploads and trybot runs.
// Before deciding to keep a cherry-pick, cherryPickCLs runs make.bash to make sure
// that the resulting tree at least compiles. If not, the error is recorded, the cherry-pick
// is rolled back, and the process continues. If the cherry-pick was an exact match for
// a pending Gerrit CL and that CL has a TryBot +1 vote, the make.bash is skipped.
// After creating each CL not already on Gerrit, cherryPickCLs mails the CL to Gerrit
// and asks for a trybot run.
//
// The result of all this is that provided the CL sequence is already on Gerrit and has
// passed all its trybot runs, cherryPickCLs runs fairly quickly and is idempotent:
// for each CL, it does only the git cherry-pick, finds the CL already on Gerrit,
// with a TryBot +1, and moves on to the next one.
//
// Note that CLs can be cherry-picked from master or pulled in from pending work
// on the release branch. In the latter case, releasebot essentially adopts the pending CL,
// pushing new revisions that set it into the right place in the overall stack.
func (w *Work) cherryPickCLs() {
lastRef := w.ReleaseBranch
lastCommit := "origin/" + w.ReleaseBranch
for _, cl := range w.CLs {
w.log.Printf("# CL %d\n", cl.Num)
if cl.Commit == "" {
w.log.Printf("SKIP - missing commit\n")
continue
}
_, err := w.runErr("git", "cherry-pick", cl.Commit)
if err != nil {
w.logError(cl, fmt.Sprintf("git cherry-pick failed:\n\n"+
" git fetch origin %s\n"+
" git checkout %s\n"+
" git fetch origin %s\n"+
" git cherry-pick %s",
lastRef, lastCommit, cl.Ref, cl.Commit))
w.run("git", "cherry-pick", "--abort")
continue
}
w.run("git", "commit", "--amend", "--no-edit") // commit hook puts [release-branch] prefix in
// Search Gerrit to find any pre-existing CL we'd be updating by doing a git mail.
// If one exists and it has the same parent, tree, and commit message as our local
// commit, the only difference is the author/committer lines and (more likely) time stamps.
// Don't bother pushing a new CL just to change those.
change := cl.ReleaseBranchGerrit
if change != nil {
log.Printf("CHECK %d\n", change.ChangeNumber)
ref := change.Revisions[change.CurrentRevision].Ref
w.runOut("git", "fetch", "origin", ref)
tree1, parent1 := w.treeAndParentOfCommit("FETCH_HEAD")
tree2, parent2 := w.treeAndParentOfCommit("HEAD")
if tree1 == tree2 && parent1 == parent2 {
w.log.Printf("reusing existing %s for CL %d", ref, cl.Num)
w.run("git", "reset", "--hard", "FETCH_HEAD")
cl.ReleaseBranchGerrit = change
} else {
log.Printf("CHECK %d fail\n", change.ChangeNumber)
change = nil
}
} else {
log.Printf("NO GERRIT %d\n", cl.Num)
}
// Before pushing to Gerrit, check that make.bash works.
// If we have a pre-existing CL we're adopting and the trybots
// say it's OK, that's even better, so skip make.bash.
if change != nil && labelValue(change, "TryBot-Result") >= +1 {
w.log.Printf("found trybot OK on Gerrit; skipping make.bash")
} else {
_, err = w.runErr("./make.bash")
if err != nil {
w.logError(cl, fmt.Sprintf("make.bash after git cherry-pick failed:\n\n"+
" git fetch origin %s\n"+
" git checkout %s\n"+
" git fetch origin %s\n"+
" git cherry-pick %s\n"+
" ./make.bash\n",
lastRef, lastCommit, cl.Ref, cl.Commit))
w.run("git", "reset", "--hard", "HEAD^")
continue
}
}
// Push to Gerrit.
if change == nil {
w.run("git", "mail", "-trybot", "HEAD")
change = w.topGerritCL()
}
cl.ReleaseBranchCL = change.ChangeNumber
cl.ReleaseBranchGerrit = change
if labelValue(change, "Code-Review") < +2 {
w.logError(cl, "missing Code-Review +2")
}
lastRef = change.Revisions[change.CurrentRevision].Ref
lastCommit = change.CurrentRevision
w.updateSummary()
}
}
// gitTagVersion tags the release candidate or release in Git.
func (w *Work) gitTagVersion() {
w.runDir = filepath.Join(w.Dir, "gitwork")
if w.FinalRelease {
w.run("git", "submit", "-i") // EDITOR=true so submits everything
w.run("git", "sync")
}
out, err := w.runErr("git", "tag", w.Version)
if err != nil {
w.logError(nil, fmt.Sprintf("git tag failed: %s\n%s", err, out))
return
}
w.run("git", "push", "origin", w.Version)
}
// topChangeID returns the Change-Id line of the top-most commit in the git client.
func (w *Work) topChangeID() string {
cmd := exec.Command("git", "cat-file", "commit", "HEAD")
cmd.Dir = w.runDir
out, err := cmd.CombinedOutput()
if err != nil {
w.log.Printf("git cat-file commit HEAD failed: %s\n%s", err, out)
panic("git")
}
var id string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
id = ""
}
if strings.HasPrefix(line, "Change-Id: ") {
id = strings.TrimPrefix(line, "Change-Id: ")
}
}
if id == "" {
w.log.Panic("cannot find Change-Id in HEAD")
}
return id
}
// topGerritCL returns the Gerrit information for the top-most commit in the git client.
func (w *Work) topGerritCL() *gerrit.ChangeInfo {
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = w.runDir
out, err := cmd.CombinedOutput()
if err != nil {
w.log.Printf("git rev-parse HEAD failed: %s\n%s", err, out)
panic("git")
}
changes, err := gerritClient.QueryChanges(context.TODO(), "commit:"+strings.TrimSpace(string(out)), gerrit.QueryChangesOpt{Fields: []string{"LABELS", "CURRENT_REVISION"}})
if err != nil {
w.log.Panic(err)
}
if len(changes) != 1 {
w.log.Panic("cannot find git HEAD on Gerrit")
}
return changes[0]
}
// treeAndParentOfCommit returns the tree and parent hashes
// for the given commit.
func (w *Work) treeAndParentOfCommit(commit string) (tree, parent string) {
out := w.runOut("git", "cat-file", "commit", commit)
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "tree ") {
tree = strings.TrimPrefix(line, "tree ")
}
if strings.HasPrefix(line, "parent ") {
parent = strings.TrimPrefix(line, "parent ")
}
if line == "" {
break
}
}
if tree == "" || parent == "" {
w.log.Panicf("getCommitInfo %s: malformed commit blob:\n%s", commit, out)
}
return
}