golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/task/tagx.go (about)

     1  // Copyright 2023 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package task
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io/fs"
    13  	"net/url"
    14  	"reflect"
    15  	"regexp"
    16  	"strconv"
    17  	"strings"
    18  	"time"
    19  
    20  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    21  	"golang.org/x/build/gerrit"
    22  	"golang.org/x/build/internal/releasetargets"
    23  	wf "golang.org/x/build/internal/workflow"
    24  	"golang.org/x/exp/slices"
    25  	"golang.org/x/mod/modfile"
    26  	"golang.org/x/mod/semver"
    27  )
    28  
    29  type TagXReposTasks struct {
    30  	IgnoreProjects map[string]bool // project name -> ignore
    31  	Gerrit         GerritClient
    32  	CloudBuild     CloudBuildClient
    33  	BuildBucket    BuildBucketClient
    34  }
    35  
    36  func (x *TagXReposTasks) NewDefinition() *wf.Definition {
    37  	wd := wf.New()
    38  	reviewers := wf.Param(wd, reviewersParam)
    39  	repos := wf.Task0(wd, "Select repositories", x.SelectRepos)
    40  	done := wf.Expand2(wd, "Create plan", x.BuildPlan, repos, reviewers)
    41  	wf.Output(wd, "done", done)
    42  	return wd
    43  }
    44  
    45  func (x *TagXReposTasks) NewSingleDefinition() *wf.Definition {
    46  	wd := wf.New()
    47  	reviewers := wf.Param(wd, reviewersParam)
    48  	repos := wf.Task0(wd, "Load all repositories", x.SelectRepos)
    49  	name := wf.Param(wd, wf.ParamDef[string]{Name: "Repository name", Example: "tools"})
    50  	// TODO: optional is required to avoid the "required" check, but since it's a checkbox
    51  	// it's obviously yes/no, should probably be exempted from that check.
    52  	skipPostSubmit := wf.Param(wd, wf.ParamDef[bool]{Name: "Skip post submit result (optional)", ParamType: wf.Bool})
    53  	tagged := wf.Expand4(wd, "Create single-repo plan", x.BuildSingleRepoPlan, repos, name, skipPostSubmit, reviewers)
    54  	wf.Output(wd, "tagged repository", tagged)
    55  	return wd
    56  }
    57  
    58  var reviewersParam = wf.ParamDef[[]string]{
    59  	Name:      "Reviewer usernames (optional)",
    60  	ParamType: wf.SliceShort,
    61  	Doc:       `Send code reviews to these users.`,
    62  	Example:   "heschi",
    63  	Check:     CheckCoordinators,
    64  }
    65  
    66  // TagRepo contains information about a repo that can be updated and possibly tagged.
    67  type TagRepo struct {
    68  	Name         string    // Gerrit project name, e.g., "tools".
    69  	ModPath      string    // Module path, e.g., "golang.org/x/tools".
    70  	Deps         []*TagDep // Dependency modules.
    71  	Compat       string    // The Go version to pass to go mod tidy -compat for this repository.
    72  	StartVersion string    // The version of the module when the workflow started. Empty string means repo hasn't begun release version tagging yet.
    73  	NewerVersion string    // The version of the module that will be tagged, or the empty string when the repo is being updated only and not tagged.
    74  }
    75  
    76  // UpdateOnlyAndNotTag reports whether repo
    77  // r should be updated only, and not tagged.
    78  func (r TagRepo) UpdateOnlyAndNotTag() bool {
    79  	if r.Name == "vuln" {
    80  		return true // x/vuln only has manual tagging for now. See go.dev/issue/59686.
    81  	}
    82  
    83  	// Consider a repo without an existing tag as one
    84  	// that hasn't yet opted in for automatic tagging.
    85  	return r.StartVersion == ""
    86  }
    87  
    88  // TagDep represents a dependency of a repo being updated and possibly tagged.
    89  type TagDep struct {
    90  	ModPath string // Module path, e.g., "golang.org/x/sys".
    91  	Wait    bool   // Wait controls whether to wait for this dependency to be processed first.
    92  }
    93  
    94  func (x *TagXReposTasks) SelectRepos(ctx *wf.TaskContext) ([]TagRepo, error) {
    95  	projects, err := x.Gerrit.ListProjects(ctx)
    96  	if err != nil {
    97  		return nil, err
    98  	}
    99  	projects = slices.DeleteFunc(projects, func(proj string) bool { return proj == "go" })
   100  
   101  	// Read the starting state for all relevant repos.
   102  	ctx.Printf("Examining repositories %v", projects)
   103  	var repos []TagRepo
   104  	var updateOnly = make(map[string]bool) // Key is module path.
   105  	for _, p := range projects {
   106  		r, err := x.readRepo(ctx, p)
   107  		if err != nil {
   108  			return nil, err
   109  		} else if r == nil {
   110  			continue
   111  		}
   112  		repos = append(repos, *r)
   113  		updateOnly[r.ModPath] = r.UpdateOnlyAndNotTag()
   114  	}
   115  	// Now that we know all repos and their deps,
   116  	// do a second pass to update the Wait field.
   117  	for _, r := range repos {
   118  		for _, dep := range r.Deps {
   119  			if updateOnly[dep.ModPath] {
   120  				// No need to wait for repos that we don't plan to tag.
   121  				dep.Wait = false
   122  			}
   123  		}
   124  	}
   125  
   126  	// Check for cycles.
   127  	var cycleProneRepos []TagRepo
   128  	for _, r := range repos {
   129  		if r.UpdateOnlyAndNotTag() {
   130  			// Cycles in repos we don't plan to tag don't matter.
   131  			continue
   132  		}
   133  		cycleProneRepos = append(cycleProneRepos, r)
   134  	}
   135  	if cycles := checkCycles(cycleProneRepos); len(cycles) != 0 {
   136  		return nil, fmt.Errorf("cycles detected (there may be more): %v", cycles)
   137  	}
   138  
   139  	return repos, nil
   140  }
   141  
   142  // readRepo fetches and returns information about the named project
   143  // to be updated and possibly tagged, or nil if the project doesn't
   144  // satisfy some criteria needed to be eligible.
   145  func (x *TagXReposTasks) readRepo(ctx *wf.TaskContext, project string) (*TagRepo, error) {
   146  	if project == "go" {
   147  		return nil, fmt.Errorf("readRepo: refusing to read the main Go repository, it's out of scope in the context of TagXReposTasks")
   148  	} else if x.IgnoreProjects[project] {
   149  		ctx.Printf("ignoring %v: marked as ignored", project)
   150  		return nil, nil
   151  	}
   152  
   153  	head, err := x.Gerrit.ReadBranchHead(ctx, project, "master")
   154  	if errors.Is(err, gerrit.ErrResourceNotExist) {
   155  		ctx.Printf("ignoring %v: no master branch: %v", project, err)
   156  		return nil, nil
   157  	} else if err != nil {
   158  		return nil, err
   159  	}
   160  
   161  	goMod, err := x.Gerrit.ReadFile(ctx, project, head, "go.mod")
   162  	if errors.Is(err, gerrit.ErrResourceNotExist) {
   163  		ctx.Printf("ignoring %v: no go.mod: %v", project, err)
   164  		return nil, nil
   165  	} else if err != nil {
   166  		return nil, err
   167  	}
   168  	mf, err := modfile.ParseLax("go.mod", goMod, nil)
   169  	if err != nil {
   170  		return nil, err
   171  	}
   172  
   173  	// TODO(heschi): ignoring nested modules for now. We should find and handle
   174  	// x/exp/event, maybe by reading release tags? But don't tag gopls...
   175  	isXRoot := func(path string) bool {
   176  		return strings.HasPrefix(path, "golang.org/x/") &&
   177  			!strings.Contains(strings.TrimPrefix(path, "golang.org/x/"), "/")
   178  	}
   179  	if !isXRoot(mf.Module.Mod.Path) {
   180  		ctx.Printf("ignoring %v: not golang.org/x", project)
   181  		return nil, nil
   182  	}
   183  
   184  	currentTag, _, err := x.latestReleaseTag(ctx, project, "")
   185  	if err != nil {
   186  		return nil, err
   187  	}
   188  
   189  	result := &TagRepo{
   190  		Name:         project,
   191  		ModPath:      mf.Module.Mod.Path,
   192  		StartVersion: currentTag,
   193  	}
   194  
   195  	compatRe := regexp.MustCompile(`tagx:compat\s+([\d.]+)`)
   196  	if mf.Go != nil {
   197  		for _, c := range mf.Go.Syntax.Comments.Suffix {
   198  			if matches := compatRe.FindStringSubmatch(c.Token); matches != nil {
   199  				result.Compat = matches[1]
   200  			}
   201  		}
   202  	}
   203  	for _, req := range mf.Require {
   204  		if !isXRoot(req.Mod.Path) {
   205  			continue
   206  		} else if x.IgnoreProjects[strings.TrimPrefix(req.Mod.Path, "golang.org/x/")] {
   207  			ctx.Printf("Dependency %v is ignored", req.Mod.Path)
   208  			continue
   209  		}
   210  		wait := true
   211  		for _, c := range req.Syntax.Comments.Suffix {
   212  			// We have cycles in the x repo dependency graph. Allow a magic
   213  			// comment, `// tagx:ignore`, to exclude requirements from
   214  			// consideration.
   215  			if strings.Contains(c.Token, "tagx:ignore") {
   216  				ctx.Printf("ignoring %v's requirement on %v: %q", project, req.Mod, c.Token)
   217  				wait = false
   218  			}
   219  		}
   220  		result.Deps = append(result.Deps, &TagDep{
   221  			ModPath: req.Mod.Path,
   222  			Wait:    wait,
   223  		})
   224  	}
   225  	return result, nil
   226  }
   227  
   228  // checkCycles returns all the shortest dependency cycles in repos.
   229  func checkCycles(repos []TagRepo) [][]string {
   230  	reposByModule := map[string]TagRepo{}
   231  	for _, repo := range repos {
   232  		reposByModule[repo.ModPath] = repo
   233  	}
   234  
   235  	var cycles [][]string
   236  
   237  	for _, repo := range reposByModule {
   238  		cycles = append(cycles, checkCycles1(reposByModule, repo, nil)...)
   239  	}
   240  
   241  	var shortestCycles [][]string
   242  	for _, cycle := range cycles {
   243  		switch {
   244  		case len(shortestCycles) == 0 || len(shortestCycles[0]) > len(cycle):
   245  			shortestCycles = [][]string{cycle}
   246  		case len(shortestCycles[0]) == len(cycle):
   247  			found := false
   248  			for _, existing := range shortestCycles {
   249  				if reflect.DeepEqual(existing, cycle) {
   250  					found = true
   251  					break
   252  				}
   253  			}
   254  			if !found {
   255  				shortestCycles = append(shortestCycles, cycle)
   256  			}
   257  		}
   258  	}
   259  	return shortestCycles
   260  }
   261  
   262  func checkCycles1(reposByModule map[string]TagRepo, repo TagRepo, stack []string) [][]string {
   263  	var cycles [][]string
   264  	stack = append(stack, repo.ModPath)
   265  	for i, s := range stack[:len(stack)-1] {
   266  		if s == repo.ModPath {
   267  			cycles = append(cycles, append([]string(nil), stack[i:]...))
   268  		}
   269  	}
   270  	if len(cycles) != 0 {
   271  		return cycles
   272  	}
   273  
   274  	for _, dep := range repo.Deps {
   275  		if !dep.Wait {
   276  			// Deps we don't wait for don't matter for cycles.
   277  			continue
   278  		}
   279  		cycles = append(cycles, checkCycles1(reposByModule, reposByModule[dep.ModPath], stack)...)
   280  	}
   281  	return cycles
   282  }
   283  
   284  // BuildPlan adds the tasks needed to update repos to wd.
   285  func (x *TagXReposTasks) BuildPlan(wd *wf.Definition, repos []TagRepo, reviewers []string) (wf.Value[string], error) {
   286  	// repo.ModPath to the wf.Value produced by planning it.
   287  	planned := map[string]wf.Value[TagRepo]{}
   288  
   289  	// Find all repositories whose dependencies are satisfied and update
   290  	// them, proceeding until all are planned or no progress can be made.
   291  	for len(planned) != len(repos) {
   292  		progress := false
   293  		for _, repo := range repos {
   294  			if _, ok := planned[repo.ModPath]; ok {
   295  				continue
   296  			}
   297  			dep, ok := x.planRepo(wd, repo, planned, reviewers, false)
   298  			if !ok {
   299  				continue
   300  			}
   301  			planned[repo.ModPath] = dep
   302  			progress = true
   303  		}
   304  
   305  		if !progress {
   306  			var missing []string
   307  			for _, r := range repos {
   308  				if planned[r.ModPath] == nil {
   309  					missing = append(missing, r.Name)
   310  				}
   311  			}
   312  			return nil, fmt.Errorf("failed to progress the plan: todo: %v", missing)
   313  		}
   314  	}
   315  	var allDeps []wf.Dependency
   316  	for _, dep := range planned {
   317  		allDeps = append(allDeps, dep)
   318  	}
   319  	done := wf.Task0(wd, "done", func(_ context.Context) (string, error) { return "done!", nil }, wf.After(allDeps...))
   320  	return done, nil
   321  }
   322  
   323  func (x *TagXReposTasks) BuildSingleRepoPlan(wd *wf.Definition, repoSlice []TagRepo, name string, skipPostSubmit bool, reviewers []string) (wf.Value[TagRepo], error) {
   324  	repos := map[string]TagRepo{}
   325  	plannedRepos := map[string]wf.Value[TagRepo]{}
   326  	for _, r := range repoSlice {
   327  		repos[r.Name] = r
   328  
   329  		// Pretend that we've just tagged version that was live when we started.
   330  		r.NewerVersion = r.StartVersion
   331  		plannedRepos[r.ModPath] = wf.Const(r)
   332  	}
   333  	repo, ok := repos[name]
   334  	if !ok {
   335  		return nil, fmt.Errorf("no repository %q", name)
   336  	}
   337  	tagged, ok := x.planRepo(wd, repo, plannedRepos, reviewers, skipPostSubmit)
   338  	if !ok {
   339  		var deps []string
   340  		for _, d := range repo.Deps {
   341  			deps = append(deps, d.ModPath)
   342  		}
   343  		return nil, fmt.Errorf("%q doesn't have all of its dependencies (%q)", repo.Name, deps)
   344  	}
   345  	return tagged, nil
   346  }
   347  
   348  // planRepo adds tasks to wf to update and possibly tag repo. It returns
   349  // a Value containing the tagged repository's information, or nil, false
   350  // if the dependencies it's waiting on haven't been planned yet.
   351  func (x *TagXReposTasks) planRepo(wd *wf.Definition, repo TagRepo, planned map[string]wf.Value[TagRepo], reviewers []string, skipPostSubmit bool) (_ wf.Value[TagRepo], ready bool) {
   352  	var plannedDeps []wf.Value[TagRepo]
   353  	for _, dep := range repo.Deps {
   354  		if !dep.Wait {
   355  			continue
   356  		} else if r, ok := planned[dep.ModPath]; ok {
   357  			plannedDeps = append(plannedDeps, r)
   358  		} else {
   359  			return nil, false
   360  		}
   361  	}
   362  	wd = wd.Sub(repo.Name)
   363  	repoName, branch := wf.Const(repo.Name), wf.Const("master")
   364  
   365  	var tagCommit wf.Value[string]
   366  	if len(plannedDeps) == 0 {
   367  		tagCommit = wf.Task2(wd, "read branch head", x.Gerrit.ReadBranchHead, repoName, branch)
   368  	} else {
   369  		goMod := wf.Task3(wd, "generate go.mod", x.UpdateGoMod, wf.Const(repo), wf.Slice(plannedDeps...), branch)
   370  		cl := wf.Task4(wd, "mail go.mod", x.MailGoMod, repoName, branch, goMod, wf.Const(reviewers))
   371  		tagCommit = wf.Task3(wd, "wait for submit", x.AwaitGoMod, cl, repoName, branch)
   372  	}
   373  	if repo.UpdateOnlyAndNotTag() {
   374  		noop := func(_ context.Context, r TagRepo, _ string) (TagRepo, error) { return r, nil }
   375  		return wf.Task2(wd, "don't tag", noop, wf.Const(repo), tagCommit), true
   376  	}
   377  	if !skipPostSubmit {
   378  		tagCommit = wf.Task2(wd, "wait for green post-submit", x.AwaitGreen, wf.Const(repo), tagCommit)
   379  	}
   380  	tagged := wf.Task2(wd, "tag if appropriate", x.MaybeTag, wf.Const(repo), tagCommit)
   381  	return tagged, true
   382  }
   383  
   384  func (x *TagXReposTasks) UpdateGoMod(ctx *wf.TaskContext, repo TagRepo, deps []TagRepo, branch string) (files map[string]string, _ error) {
   385  	// Update the root module to the selected versions.
   386  	var script strings.Builder
   387  	script.WriteString("go get")
   388  	for _, dep := range deps {
   389  		script.WriteString(" " + dep.ModPath + "@" + dep.NewerVersion)
   390  	}
   391  	script.WriteString("\n")
   392  
   393  	// Tidy the root module.
   394  	// Also tidy nested modules with a replace directive.
   395  	dirs := []string{"."}
   396  	switch repo.Name {
   397  	case "exp":
   398  		dirs = append(dirs, "slog/benchmarks/zap_benchmarks")     // A local replace directive as of 2023-09-05.
   399  		dirs = append(dirs, "slog/benchmarks/zerolog_benchmarks") // A local replace directive as of 2023-09-05.
   400  	case "telemetry":
   401  		dirs = append(dirs, "godev") // A local replace directive as of 2023-09-05.
   402  	case "tools":
   403  		dirs = append(dirs, "gopls") // A local replace directive as of 2023-09-05.
   404  	}
   405  	var outputs []string
   406  	for _, dir := range dirs {
   407  		compat := ""
   408  		if repo.Compat != "" {
   409  			compat = "-compat " + repo.Compat
   410  		}
   411  		script.WriteString(fmt.Sprintf("(cd %v && touch go.sum && go mod tidy %v)\n", dir, compat))
   412  		outputs = append(outputs, dir+"/go.mod", dir+"/go.sum")
   413  	}
   414  	build, err := x.CloudBuild.RunScript(ctx, script.String(), repo.Name, outputs)
   415  	if err != nil {
   416  		return nil, err
   417  	}
   418  	return buildToOutputs(ctx, x.CloudBuild, build)
   419  }
   420  
   421  func buildToOutputs(ctx *wf.TaskContext, buildClient CloudBuildClient, build CloudBuild) (map[string]string, error) {
   422  	if _, err := AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) {
   423  		return buildClient.Completed(ctx, build)
   424  	}); err != nil {
   425  		return nil, err
   426  	}
   427  
   428  	outfs, err := buildClient.ResultFS(ctx, build)
   429  	if err != nil {
   430  		return nil, err
   431  	}
   432  	outMap := map[string]string{}
   433  	return outMap, fs.WalkDir(outfs, ".", func(path string, d fs.DirEntry, err error) error {
   434  		if d.IsDir() {
   435  			return nil
   436  		}
   437  		bytes, err := fs.ReadFile(outfs, path)
   438  		outMap[path] = string(bytes)
   439  		return err
   440  	})
   441  }
   442  
   443  func (x *TagXReposTasks) MailGoMod(ctx *wf.TaskContext, repo, branch string, files map[string]string, reviewers []string) (string, error) {
   444  	const subject = `go.mod: update golang.org/x dependencies
   445  
   446  Update golang.org/x dependencies to their latest tagged versions.`
   447  	return x.Gerrit.CreateAutoSubmitChange(ctx, gerrit.ChangeInput{
   448  		Project: repo,
   449  		Branch:  branch,
   450  		Subject: subject,
   451  	}, reviewers, files)
   452  }
   453  
   454  func (x *TagXReposTasks) AwaitGoMod(ctx *wf.TaskContext, changeID, repo, branch string) (string, error) {
   455  	if changeID == "" {
   456  		ctx.Printf("No CL was necessary")
   457  		return x.Gerrit.ReadBranchHead(ctx, repo, branch)
   458  	}
   459  
   460  	ctx.Printf("Awaiting review/submit of %v", ChangeLink(changeID))
   461  	return AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) {
   462  		return x.Gerrit.Submitted(ctx, changeID, "")
   463  	})
   464  }
   465  
   466  func (x *TagXReposTasks) AwaitGreen(ctx *wf.TaskContext, repo TagRepo, commit string) (string, error) {
   467  	// Check if commit is already the latest tagged version.
   468  	// If so, it's deemed green and there's no need to wait.
   469  	if _, highestReleaseTagIsCommit, err := x.latestReleaseTag(ctx, repo.Name, commit); err != nil {
   470  		return "", err
   471  	} else if highestReleaseTagIsCommit {
   472  		return commit, nil
   473  	}
   474  
   475  	head, err := x.Gerrit.ReadBranchHead(ctx, repo.Name, "master")
   476  	if err != nil {
   477  		return "", err
   478  	}
   479  	ctx.Printf("Checking if %v is green", head)
   480  	missing, err := x.findMissingBuilders(ctx, repo, head)
   481  	if err != nil {
   482  		return "", err
   483  	}
   484  	return head, x.runMissingBuilders(ctx, repo, head, missing)
   485  }
   486  
   487  func (x *TagXReposTasks) findMissingBuilders(ctx *wf.TaskContext, repo TagRepo, head string) (map[string]bool, error) {
   488  	builders, err := x.BuildBucket.ListBuilders(ctx, "ci")
   489  	if err != nil {
   490  		return nil, err
   491  	}
   492  
   493  	var wantBuilders = make(map[string]bool)
   494  	for name, b := range builders {
   495  		type port struct {
   496  			GOOS   string `json:"goos"`
   497  			GOARCH string `json:"goarch"`
   498  		}
   499  		var props struct {
   500  			BuilderMode int    `json:"mode"`
   501  			Project     string `json:"project"`
   502  			IsGoogle    bool   `json:"is_google"`
   503  			KnownIssue  int    `json:"known_issue"`
   504  			Target      port   `json:"target"`
   505  		}
   506  		if err := json.Unmarshal([]byte(b.Properties), &props); err != nil {
   507  			return nil, fmt.Errorf("error unmarshaling properties for %v: %v", name, err)
   508  		}
   509  		if props.Project != repo.Name || !props.IsGoogle || !releasetargets.IsFirstClass(props.Target.GOOS, props.Target.GOARCH) {
   510  			continue
   511  		}
   512  		var skip []string // Log-worthy causes of skip, if any.
   513  		// golangbuildModePerf is golangbuild's MODE_PERF mode that
   514  		// runs benchmarks. It's the first custom mode not relevant
   515  		// to building and testing, and the expectation is that any
   516  		// modes after it will be fine to skip for release purposes.
   517  		//
   518  		// See https://source.chromium.org/chromium/infra/infra/+/main:go/src/infra/experimental/golangbuild/golangbuildpb/params.proto;l=174-177;drc=fdea4abccf8447808d4e702c8d09fdd20fd81acb.
   519  		const golangbuildModePerf = 4
   520  		if props.BuilderMode >= golangbuildModePerf {
   521  			skip = append(skip, fmt.Sprintf("custom mode %d", props.BuilderMode))
   522  		}
   523  		if props.KnownIssue != 0 {
   524  			skip = append(skip, fmt.Sprintf("known issue %d", props.KnownIssue))
   525  		}
   526  		if len(skip) != 0 {
   527  			ctx.Printf("skipping %s because of %s", name, strings.Join(skip, ", "))
   528  			continue
   529  		}
   530  		wantBuilders[name] = true
   531  	}
   532  
   533  	for name := range wantBuilders {
   534  		pred := &buildbucketpb.BuildPredicate{
   535  			Builder: &buildbucketpb.BuilderID{Project: "golang", Bucket: "ci", Builder: name},
   536  			Tags: []*buildbucketpb.StringPair{
   537  				{Key: "buildset", Value: fmt.Sprintf("commit/gitiles/%s/%s/+/%s", hostFromURL(x.Gerrit.GitilesURL()), repo.Name, head)},
   538  			},
   539  			Status: buildbucketpb.Status_SUCCESS,
   540  		}
   541  		succesfulBuilds, err := x.BuildBucket.SearchBuilds(ctx, pred)
   542  		if err != nil {
   543  			return nil, err
   544  		}
   545  		if len(succesfulBuilds) != 0 {
   546  			ctx.Printf("%v: found successful builds: %v", name, succesfulBuilds)
   547  			delete(wantBuilders, name)
   548  		} else {
   549  			ctx.Printf("%v: no successful builds", name)
   550  		}
   551  	}
   552  	return wantBuilders, nil
   553  }
   554  
   555  func (x *TagXReposTasks) runMissingBuilders(ctx *wf.TaskContext, repo TagRepo, head string, builders map[string]bool) error {
   556  	wantBuilds := map[string]int64{}
   557  	for id := range builders {
   558  		buildID, err := x.BuildBucket.RunBuild(ctx, "ci", id, &buildbucketpb.GitilesCommit{
   559  			Host:    hostFromURL(x.Gerrit.GitilesURL()),
   560  			Project: repo.Name,
   561  			Id:      head,
   562  			Ref:     "refs/heads/master",
   563  		}, nil)
   564  		if err != nil {
   565  			return err
   566  		}
   567  		wantBuilds[id] = buildID
   568  		ctx.Printf("%v: scheduled build %v", id, buildID)
   569  	}
   570  	_, err := AwaitCondition(ctx, time.Minute, func() (string, bool, error) {
   571  		for builderID, buildID := range wantBuilds {
   572  			_, done, err := x.BuildBucket.Completed(ctx, buildID)
   573  			if !done {
   574  				continue
   575  			}
   576  			if err != nil {
   577  				return "", true, fmt.Errorf("at least one build failed: %v: %v", builderID, err)
   578  			}
   579  			delete(wantBuilds, builderID)
   580  		}
   581  		return "", len(wantBuilds) == 0, nil
   582  	})
   583  	return err
   584  }
   585  
   586  // MaybeTag tags repo at commit with the next version, unless commit is already
   587  // the latest tagged version. repo is returned with NewerVersion populated.
   588  func (x *TagXReposTasks) MaybeTag(ctx *wf.TaskContext, repo TagRepo, commit string) (TagRepo, error) {
   589  	// Check if commit is already the latest tagged version.
   590  	highestRelease, highestReleaseTagIsCommit, err := x.latestReleaseTag(ctx, repo.Name, commit)
   591  	if err != nil {
   592  		return TagRepo{}, err
   593  	} else if highestRelease == "" {
   594  		return TagRepo{}, fmt.Errorf("no semver tags found in %v", repo.Name)
   595  	}
   596  	if highestReleaseTagIsCommit {
   597  		repo.NewerVersion = highestRelease
   598  		return repo, nil
   599  	}
   600  
   601  	// Tag commit.
   602  	repo.NewerVersion, err = nextMinor(highestRelease)
   603  	if err != nil {
   604  		return TagRepo{}, fmt.Errorf("couldn't pick next version for %v: %v", repo.Name, err)
   605  	}
   606  	ctx.Printf("Tagging %v at %v as %v", repo.Name, commit, repo.NewerVersion)
   607  	return repo, x.Gerrit.Tag(ctx, repo.Name, repo.NewerVersion, commit)
   608  }
   609  
   610  // latestReleaseTag fetches tags for repo and returns the latest release tag,
   611  // or the empty string if there are no release tags. It also reports whether
   612  // commit, if provided, matches the latest release tag's revision.
   613  func (x *TagXReposTasks) latestReleaseTag(ctx context.Context, repo, commit string) (highestRelease string, isCommit bool, _ error) {
   614  	tags, err := x.Gerrit.ListTags(ctx, repo)
   615  	if err != nil {
   616  		return "", false, fmt.Errorf("listing project %q tags: %v", repo, err)
   617  	}
   618  	for _, tag := range tags {
   619  		if semver.IsValid(tag) && semver.Prerelease(tag) == "" &&
   620  			(highestRelease == "" || semver.Compare(highestRelease, tag) < 0) {
   621  			highestRelease = tag
   622  		}
   623  	}
   624  	if commit != "" && highestRelease != "" {
   625  		tagInfo, err := x.Gerrit.GetTag(ctx, repo, highestRelease)
   626  		if err != nil {
   627  			return "", false, fmt.Errorf("reading project %q tag %q: %v", repo, highestRelease, err)
   628  		}
   629  		isCommit = tagInfo.Revision == commit
   630  	}
   631  	return highestRelease, isCommit, nil
   632  }
   633  
   634  var majorMinorRestRe = regexp.MustCompile(`^v(\d+)\.(\d+)\..*$`)
   635  
   636  func nextMinor(version string) (string, error) {
   637  	parts := majorMinorRestRe.FindStringSubmatch(version)
   638  	if parts == nil {
   639  		return "", fmt.Errorf("malformatted version %q", version)
   640  	}
   641  	minor, err := strconv.Atoi(parts[2])
   642  	if err != nil {
   643  		return "", fmt.Errorf("malformatted version %q (%v)", version, err)
   644  	}
   645  	return fmt.Sprintf("v%s.%d.0", parts[1], minor+1), nil
   646  }
   647  
   648  func hostFromURL(s string) string {
   649  	u, _ := url.Parse(s)
   650  	return u.Host
   651  }