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 }