| // Copyright 2020 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 task |
| |
| import ( |
| "errors" |
| "fmt" |
| "slices" |
| "strconv" |
| "strings" |
| "time" |
| |
| "github.com/google/go-github/v48/github" |
| "golang.org/x/build/gerrit" |
| "golang.org/x/build/internal/relui/groups" |
| wf "golang.org/x/build/internal/workflow" |
| "golang.org/x/mod/semver" |
| ) |
| |
| // ReleaseGoplsTasks provides workflow definitions and tasks for releasing gopls. |
| type ReleaseGoplsTasks struct { |
| Github GitHubClientInterface |
| Gerrit GerritClient |
| CloudBuild CloudBuildClient |
| SendMail func(MailHeader, MailContent) error |
| AnnounceMailHeader MailHeader |
| ApproveAction func(*wf.TaskContext) error |
| } |
| |
| // NewPrereleaseDefinition create a new workflow definition for gopls pre-release. |
| func (r *ReleaseGoplsTasks) NewPrereleaseDefinition() *wf.Definition { |
| wd := wf.New(wf.ACL{Groups: []string{groups.ToolsTeam}}) |
| |
| // versionBumpStrategy specifies the desired release type: next minor, next |
| // patch or use explicit version. |
| // This should be the default choice for most releases. |
| versionBumpStrategy := wf.Param(wd, nextVersionParam) |
| // inputVersion allows manual override of the version, bypassing the version |
| // bump strategy. |
| // Use with caution. |
| inputVersion := wf.Param(wd, wf.ParamDef[string]{Name: "explicit version (optional)"}) |
| reviewers := wf.Param(wd, reviewersParam) |
| |
| release := wf.Task2(wd, "determine the release version", r.determineReleaseVersion, inputVersion, versionBumpStrategy) |
| prerelease := wf.Task1(wd, "find the next pre-release version", r.nextPrereleaseVersion, release) |
| approved := wf.Action2(wd, "wait for release coordinator approval", r.approvePrerelease, release, prerelease) |
| |
| issue := wf.Task2(wd, "create release git issue", r.findOrCreateGitHubIssue, release, wf.Const(true), wf.After(approved)) |
| branchCreated := wf.Action1(wd, "create new branch if minor release", r.createBranchIfMinor, release, wf.After(issue)) |
| |
| configChangeID := wf.Task3(wd, "update branch's codereview.cfg", r.updateCodeReviewConfig, release, reviewers, issue, wf.After(branchCreated)) |
| configCommit := wf.Task1(wd, "await config CL submission", clAwaiter{r.Gerrit}.awaitSubmission, configChangeID) |
| |
| dependencyChangeID := wf.Task4(wd, "update gopls' x/tools dependency", r.updateXToolsDependency, release, prerelease, reviewers, issue, wf.After(configCommit)) |
| dependencyCommit := wf.Task1(wd, "await gopls' x/tools dependency CL submission", clAwaiter{r.Gerrit}.awaitSubmission, dependencyChangeID) |
| |
| verified := wf.Action1(wd, "verify installing latest gopls using release branch dependency commit", r.verifyGoplsInstallation, dependencyCommit) |
| prereleaseVersion := wf.Task3(wd, "tag pre-release", r.tagPrerelease, release, dependencyCommit, prerelease, wf.After(verified)) |
| prereleaseVerified := wf.Action1(wd, "verify installing latest gopls using release branch pre-release version", r.verifyGoplsInstallation, prereleaseVersion) |
| wf.Action4(wd, "mail announcement", r.mailAnnouncement, release, prereleaseVersion, dependencyCommit, issue, wf.After(prereleaseVerified)) |
| |
| vscodeGoChanges := wf.Task4(wd, "update gopls version in vscode-go", r.updateVSCodeGoGoplsVersion, reviewers, issue, release, prerelease, wf.After(prereleaseVerified)) |
| _ = wf.Task1(wd, "await gopls version update CLs submission in vscode-go", clAwaiter{r.Gerrit}.awaitSubmissions, vscodeGoChanges) |
| |
| wf.Output(wd, "version", prereleaseVersion) |
| |
| return wd |
| } |
| |
| // determineReleaseVersion returns the release version based on coordinator inputs. |
| // |
| // Returns the specified input version if provided; otherwise, interpret a new |
| // version based on the version bumping strategy. |
| func (r *ReleaseGoplsTasks) determineReleaseVersion(ctx *wf.TaskContext, inputVersion, versionBumpStrategy string) (semversion, error) { |
| switch versionBumpStrategy { |
| case "use explicit version": |
| if inputVersion == "" { |
| return semversion{}, fmt.Errorf("the input version should not be empty when choosing explicit version release") |
| } |
| if err := r.isValidReleaseVersion(ctx, inputVersion); err != nil { |
| return semversion{}, err |
| } |
| semv, ok := parseSemver(inputVersion) |
| if !ok { |
| return semversion{}, fmt.Errorf("input version %q can not be parsed as semantic version", inputVersion) |
| } |
| return semv, nil |
| case "next minor", "next patch": |
| return r.interpretNextRelease(ctx, versionBumpStrategy) |
| default: |
| return semversion{}, fmt.Errorf("unknown version selection strategy: %q", versionBumpStrategy) |
| } |
| } |
| |
| func (r *ReleaseGoplsTasks) interpretNextRelease(ctx *wf.TaskContext, versionBumpStrategy string) (semversion, error) { |
| tags, err := r.Gerrit.ListTags(ctx, "tools") |
| if err != nil { |
| return semversion{}, err |
| } |
| |
| var versions []string |
| for _, tag := range tags { |
| if v, ok := strings.CutPrefix(tag, "gopls/"); ok { |
| versions = append(versions, v) |
| } |
| } |
| |
| version := latestVersion(versions, isReleaseVersion) |
| switch versionBumpStrategy { |
| case "next minor": |
| version.Minor += 1 |
| version.Patch = 0 |
| case "next patch": |
| version.Patch += 1 |
| default: |
| return semversion{}, fmt.Errorf("unknown version selection strategy: %q", versionBumpStrategy) |
| } |
| |
| return version, nil |
| } |
| |
| // approvePrerelease prompts the approval for creating a pre-release version. |
| func (r *ReleaseGoplsTasks) approvePrerelease(ctx *wf.TaskContext, semv semversion, pre string) error { |
| ctx.Printf("The next release candidate will be v%v.%v.%v-%s", semv.Major, semv.Minor, semv.Patch, pre) |
| |
| return r.ApproveAction(ctx) |
| } |
| |
| // approveRelease prompts the approval for releasing a pre-release version. |
| func (r *ReleaseGoplsTasks) approveRelease(ctx *wf.TaskContext, semv semversion, pre string) error { |
| ctx.Printf("The release candidate v%v.%v.%v-%s will be released", semv.Major, semv.Minor, semv.Patch, pre) |
| |
| return r.ApproveAction(ctx) |
| } |
| |
| // findOrCreateGitHubIssue locates or creates the release issue for the given |
| // release milestone. |
| // |
| // If the release issue exists, return the issue ID. |
| // If 'create' is true and no issue exists, a new one is created. |
| // If 'create' is false and no issue exists, an error is returned. |
| func (r *ReleaseGoplsTasks) findOrCreateGitHubIssue(ctx *wf.TaskContext, semv semversion, create bool) (int64, error) { |
| versionString := fmt.Sprintf("v%v.%v.%v", semv.Major, semv.Minor, semv.Patch) |
| milestoneName := fmt.Sprintf("gopls/%s", versionString) |
| // All milestones and issues resides under go repo. |
| milestoneID, err := r.Github.FetchMilestone(ctx, "golang", "go", milestoneName, false) |
| if err != nil { |
| return 0, err |
| } |
| ctx.Printf("found release milestone %v", milestoneID) |
| issues, err := r.Github.FetchMilestoneIssues(ctx, "golang", "go", milestoneID) |
| if err != nil { |
| return 0, err |
| } |
| |
| title := fmt.Sprintf("x/tools/gopls: release version %s", versionString) |
| for id := range issues { |
| issue, _, err := r.Github.GetIssue(ctx, "golang", "go", id) |
| if err != nil { |
| return 0, err |
| } |
| if title == issue.GetTitle() { |
| ctx.Printf("found existing releasing issue %v", id) |
| return int64(id), nil |
| } |
| } |
| |
| if !create { |
| return 0, fmt.Errorf("could not find any release issue for %s", versionString) |
| } |
| |
| ctx.DisableRetries() |
| content := fmt.Sprintf(`This issue tracks progress toward releasing gopls@%s |
| |
| - [ ] create or update %s |
| - [ ] update go.mod/go.sum (remove x/tools replace, update x/tools version) |
| - [ ] tag gopls/%s-pre.1 |
| - [ ] update Github milestone |
| - [ ] write release notes |
| - [ ] smoke test features |
| - [ ] tag gopls/%s |
| - [ ] (if vX.Y.0 release): update dependencies in master for the next release |
| `, versionString, goplsReleaseBranchName(semv), versionString, versionString) |
| // TODO(hxjiang): accept a new parameter release coordinator. |
| assignee := "h9jiang" |
| issue, _, err := r.Github.CreateIssue(ctx, "golang", "go", &github.IssueRequest{ |
| Title: &title, |
| Body: &content, |
| Labels: &[]string{"gopls", "Tools"}, |
| Assignee: &assignee, |
| Milestone: &milestoneID, |
| }) |
| if err != nil { |
| return 0, fmt.Errorf("failed to create release tracking issue for %q: %w", versionString, err) |
| } |
| ctx.Printf("created releasing issue %v", *issue.Number) |
| return int64(*issue.Number), nil |
| } |
| |
| // goplsReleaseBranchName returns the branch name for given input release version. |
| func goplsReleaseBranchName(semv semversion) string { |
| return fmt.Sprintf("gopls-release-branch.%v.%v", semv.Major, semv.Minor) |
| } |
| |
| // createBranchIfMinor create the release branch if the input version is a minor |
| // release. |
| // All patch releases under the same minor version share the same release branch. |
| func (r *ReleaseGoplsTasks) createBranchIfMinor(ctx *wf.TaskContext, semv semversion) error { |
| branch := goplsReleaseBranchName(semv) |
| |
| // Require gopls release branch existence if this is a non-minor release. |
| if semv.Patch != 0 { |
| _, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch) |
| return err |
| } |
| |
| // Return early if the branch already exist. |
| // This scenario should only occur if the initial minor release flow failed |
| // or was interrupted and subsequently re-triggered. |
| if _, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch); err == nil { |
| return nil |
| } |
| |
| // Create the release branch using the revision from the head of master branch. |
| head, err := r.Gerrit.ReadBranchHead(ctx, "tools", "master") |
| if err != nil { |
| return err |
| } |
| |
| ctx.Printf("Creating branch %s at revision %s.\n", branch, head) |
| _, err = r.Gerrit.CreateBranch(ctx, "tools", branch, gerrit.BranchInput{Revision: head}) |
| return err |
| } |
| |
| // openCL checks if an open CL with the given title exists in the specified |
| // branch. |
| // |
| // It returns an empty string if no such CL is found, otherwise it returns the |
| // CL's change ID. |
| func openCL(ctx *wf.TaskContext, gerrit GerritClient, repo, branch, title string) (string, error) { |
| // Query for an existing pending config CL, to avoid duplication. |
| query := fmt.Sprintf(`message:%q status:open owner:gobot@golang.org repo:%s branch:%q -age:7d`, title, repo, branch) |
| changes, err := gerrit.QueryChanges(ctx, query) |
| if err != nil { |
| return "", err |
| } |
| if len(changes) == 0 { |
| return "", nil |
| } |
| |
| return changes[0].ChangeID, nil |
| } |
| |
| // updateCodeReviewConfig checks if codereview.cfg has the desired configuration. |
| // |
| // It returns the change ID required to update the config if changes are needed, |
| // otherwise it returns an empty string indicating no update is necessary. |
| func (r *ReleaseGoplsTasks) updateCodeReviewConfig(ctx *wf.TaskContext, semv semversion, reviewers []string, issue int64) (string, error) { |
| const configFile = "codereview.cfg" |
| |
| branch := goplsReleaseBranchName(semv) |
| clTitle := fmt.Sprintf("all: update %s for %s", configFile, branch) |
| |
| openCL, err := openCL(ctx, r.Gerrit, "tools", branch, clTitle) |
| if err != nil { |
| return "", fmt.Errorf("failed to find the open CL of title %q in branch %q: %w", clTitle, branch, err) |
| } |
| if openCL != "" { |
| ctx.Printf("not creating CL: found existing CL %s", openCL) |
| return openCL, nil |
| } |
| |
| head, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch) |
| if err != nil { |
| return "", err |
| } |
| |
| before, err := r.Gerrit.ReadFile(ctx, "tools", head, configFile) |
| if err != nil && !errors.Is(err, gerrit.ErrResourceNotExist) { |
| return "", err |
| } |
| const configFmt = `issuerepo: golang/go |
| branch: %s |
| parent-branch: master |
| ` |
| after := fmt.Sprintf(configFmt, branch) |
| // Skip CL creation as config has not changed. |
| if string(before) == after { |
| return "", nil |
| } |
| |
| changeInput := gerrit.ChangeInput{ |
| Project: "tools", |
| Subject: fmt.Sprintf("%s\n\nThis is an automated CL which updates the %s.\n\nFor golang/go#%v", clTitle, configFile, issue), |
| Branch: branch, |
| } |
| |
| files := map[string]string{ |
| configFile: string(after), |
| } |
| |
| ctx.Printf("creating auto-submit change to %s under branch %q in x/tools repo.", configFile, branch) |
| return r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, files) |
| } |
| |
| // nextPrereleaseVersion inspects the tags in tools repo that match with the given |
| // version and finds the next prerelease version. |
| func (r *ReleaseGoplsTasks) nextPrereleaseVersion(ctx *wf.TaskContext, semv semversion) (string, error) { |
| tags, err := r.Gerrit.ListTags(ctx, "tools") |
| if err != nil { |
| return "", err |
| } |
| |
| var versions []string |
| for _, tag := range tags { |
| if v, ok := strings.CutPrefix(tag, "gopls/"); ok { |
| versions = append(versions, v) |
| } |
| } |
| |
| rc := latestVersion(versions, isSameMajorMinorPatch(semv), isPrereleaseMatchRegex(`^pre\.\d+$`)) |
| if rc == (semversion{}) { |
| return "pre.1", nil |
| } |
| pre, err := rc.prereleaseVersion() |
| if err != nil { |
| return "", err |
| } |
| return fmt.Sprintf("pre.%v", pre+1), nil |
| } |
| |
| // currentGoplsPrerelease inspects the tags in tools repo that match with the |
| // given version and find the latest pre-release version. |
| func currentGoplsPrerelease(ctx *wf.TaskContext, client GerritClient, semv semversion) (int, error) { |
| tags, err := client.ListTags(ctx, "tools") |
| if err != nil { |
| return 0, fmt.Errorf("failed to list tags for tools repo: %w", err) |
| } |
| |
| max := 0 |
| for _, tag := range tags { |
| v, ok := strings.CutPrefix(tag, "gopls/") |
| if !ok { |
| continue |
| } |
| cur, ok := parseSemver(v) |
| if !ok { |
| continue |
| } |
| if cur.Major != semv.Major || cur.Minor != semv.Minor || cur.Patch != semv.Patch { |
| continue |
| } |
| pre, err := cur.prereleaseVersion() |
| if err != nil { |
| continue |
| } |
| |
| if pre > max { |
| max = pre |
| } |
| } |
| |
| return max, nil |
| } |
| |
| // updateXToolsDependency ensures gopls sub module have the correct x/tools |
| // version as dependency. |
| // |
| // It returns the change ID, or "" if the CL was not created. |
| func (r *ReleaseGoplsTasks) updateXToolsDependency(ctx *wf.TaskContext, semv semversion, pre string, reviewers []string, issue int64) (string, error) { |
| if pre == "" { |
| return "", fmt.Errorf("the input pre-release version should not be empty") |
| } |
| |
| branch := goplsReleaseBranchName(semv) |
| clTitle := fmt.Sprintf("gopls: update go.mod for v%v.%v.%v-%s", semv.Major, semv.Minor, semv.Patch, pre) |
| openCL, err := openCL(ctx, r.Gerrit, "tools", branch, clTitle) |
| if err != nil { |
| return "", fmt.Errorf("failed to find the open CL of title %q in branch %q: %w", clTitle, branch, err) |
| } |
| if openCL != "" { |
| ctx.Printf("not creating CL: found existing CL %s", openCL) |
| return openCL, nil |
| } |
| |
| head, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch) |
| if err != nil { |
| return "", err |
| } |
| // TODO(hxjiang): Remove -compat flag once gopls no longer supports building |
| // with older Go versions. |
| script := fmt.Sprintf(`cd gopls |
| go mod edit -dropreplace=golang.org/x/tools |
| go get golang.org/x/tools@%s |
| go mod tidy -compat=1.19 |
| `, head) |
| |
| changedFiles, err := executeAndMonitorChange(ctx, r.CloudBuild, "tools", branch, script, []string{"gopls/go.mod", "gopls/go.sum"}) |
| if err != nil { |
| return "", err |
| } |
| |
| // Skip CL creation as nothing changed. |
| if len(changedFiles) == 0 { |
| return "", nil |
| } |
| |
| changeInput := gerrit.ChangeInput{ |
| Project: "tools", |
| Branch: branch, |
| Subject: fmt.Sprintf("%s\n\nThis is an automated CL which updates the go.mod and go.sum.\n\nFor golang/go#%v", clTitle, issue), |
| } |
| |
| ctx.Printf("creating auto-submit change under branch %q in x/tools repo.", branch) |
| return r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, changedFiles) |
| } |
| |
| // verifyGoplsInstallation installs the gopls with the provided version and run |
| // smoke test. |
| // The input version can be a commit, a branch name, a semantic version, see |
| // more detail https://go.dev/ref/mod#version-queries |
| func (r *ReleaseGoplsTasks) verifyGoplsInstallation(ctx *wf.TaskContext, version string) error { |
| if version == "" { |
| return fmt.Errorf("the input version should not be empty") |
| } |
| const scriptFmt = `go install golang.org/x/tools/gopls@%s &> install.log |
| $(go env GOPATH)/bin/gopls version &> version.log |
| echo -n "package main |
| |
| func main () { |
| const a = 2 |
| b := a |
| }" > main.go |
| $(go env GOPATH)/bin/gopls references -d main.go:4:8 &> smoke.log |
| ` |
| |
| ctx.Printf("verify gopls with version %s\n", version) |
| build, err := r.CloudBuild.RunScript(ctx, fmt.Sprintf(scriptFmt, version), "", []string{"install.log", "version.log", "smoke.log"}) |
| if err != nil { |
| return err |
| } |
| |
| outputs, err := buildToOutputs(ctx, r.CloudBuild, build) |
| if err != nil { |
| return err |
| } |
| ctx.Printf("verify gopls installation process:\n%s\n", outputs["install.log"]) |
| ctx.Printf("verify gopls version:\n%s\n", outputs["version.log"]) |
| ctx.Printf("verify gopls functionality with gopls references smoke test:\n%s\n", outputs["smoke.log"]) |
| return nil |
| } |
| |
| // tagPrerelease applies gopls pre-release tags to the given commit. |
| // The input semversion provides Major, Minor, and Patch info. |
| // The input pre-release, generated by previous steps of the workflow, provides |
| // Pre-release info. |
| func (r *ReleaseGoplsTasks) tagPrerelease(ctx *wf.TaskContext, semv semversion, commit, pre string) (string, error) { |
| if commit == "" { |
| return "", fmt.Errorf("the input commit should not be empty") |
| } |
| if pre == "" { |
| return "", fmt.Errorf("the input pre-release version should not be empty") |
| } |
| |
| // Defensively guard against re-creating tags. |
| ctx.DisableRetries() |
| |
| version := fmt.Sprintf("v%v.%v.%v-%s", semv.Major, semv.Minor, semv.Patch, pre) |
| tag := fmt.Sprintf("gopls/%s", version) |
| if err := r.Gerrit.Tag(ctx, "tools", tag, commit); err != nil { |
| return "", err |
| } |
| |
| ctx.Printf("tagged commit %s with tag %s", commit, tag) |
| return version, nil |
| } |
| |
| type goplsPrereleaseAnnouncement struct { |
| Version string |
| Branch string |
| Commit string |
| Issue int64 |
| } |
| |
| func (r *ReleaseGoplsTasks) mailAnnouncement(ctx *wf.TaskContext, semv semversion, prerelease, commit string, issue int64) error { |
| announce := goplsPrereleaseAnnouncement{ |
| Version: prerelease, |
| Branch: goplsReleaseBranchName(semv), |
| Commit: commit, |
| Issue: issue, |
| } |
| content, err := announcementMail(announce) |
| if err != nil { |
| return err |
| } |
| ctx.Printf("pre-announcement subject: %s\n\n", content.Subject) |
| ctx.Printf("pre-announcement body HTML:\n%s\n", content.BodyHTML) |
| ctx.Printf("pre-announcement body text:\n%s", content.BodyText) |
| return r.SendMail(r.AnnounceMailHeader, content) |
| } |
| |
| func (r *ReleaseGoplsTasks) isValidReleaseVersion(ctx *wf.TaskContext, ver string) error { |
| if !semver.IsValid(ver) { |
| return fmt.Errorf("the input %q version does not follow semantic version schema", ver) |
| } |
| |
| versions, err := r.possibleGoplsVersions(ctx) |
| if err != nil { |
| return fmt.Errorf("failed to get latest Gopls version tags from x/tool: %w", err) |
| } |
| |
| if !slices.Contains(versions, ver) { |
| return fmt.Errorf("the input %q is not next version of any existing versions", ver) |
| } |
| |
| return nil |
| } |
| |
| // semversion is a parsed semantic version. |
| type semversion struct { |
| Major, Minor, Patch int |
| Pre string |
| } |
| |
| // parseSemver attempts to parse semver components out of the provided semver |
| // v. If v is not valid semver in canonical form, parseSemver returns false. |
| func parseSemver(v string) (_ semversion, ok bool) { |
| var parsed semversion |
| v, parsed.Pre, _ = strings.Cut(v, "-") |
| if _, err := fmt.Sscanf(v, "v%d.%d.%d", &parsed.Major, &parsed.Minor, &parsed.Patch); err == nil { |
| ok = true |
| } |
| return parsed, ok |
| } |
| |
| // prereleaseVersion extracts the integer component from a pre-release version |
| // string in the format "${STRING}.${INT}". |
| func (s *semversion) prereleaseVersion() (int, error) { |
| parts := strings.Split(s.Pre, ".") |
| if len(parts) == 1 { |
| return 0, fmt.Errorf(`pre-release version does not contain any "."`) |
| } |
| |
| if len(parts) > 2 { |
| return 0, fmt.Errorf(`pre-release version contains %v "."`, len(parts)-1) |
| } |
| |
| pre, err := strconv.Atoi(parts[1]) |
| if err != nil { |
| return 0, fmt.Errorf("failed to convert pre-release version to int %q: %w", pre, err) |
| } |
| |
| if pre <= 0 { |
| return 0, fmt.Errorf("the pre-release version should be larger than 0: %v", pre) |
| } |
| |
| return pre, nil |
| } |
| |
| // possibleGoplsVersions identifies suitable versions for the upcoming release |
| // based on the current tags in the repo. |
| func (r *ReleaseGoplsTasks) possibleGoplsVersions(ctx *wf.TaskContext) ([]string, error) { |
| tags, err := r.Gerrit.ListTags(ctx, "tools") |
| if err != nil { |
| return nil, err |
| } |
| |
| var semVersions []semversion |
| majorMinorPatch := map[int]map[int]map[int]bool{} |
| for _, tag := range tags { |
| v, ok := strings.CutPrefix(tag, "gopls/") |
| if !ok { |
| continue |
| } |
| |
| if !semver.IsValid(v) { |
| continue |
| } |
| |
| // Skip for pre-release versions. |
| if semver.Prerelease(v) != "" { |
| continue |
| } |
| |
| semv, ok := parseSemver(v) |
| semVersions = append(semVersions, semv) |
| |
| if majorMinorPatch[semv.Major] == nil { |
| majorMinorPatch[semv.Major] = map[int]map[int]bool{} |
| } |
| if majorMinorPatch[semv.Major][semv.Minor] == nil { |
| majorMinorPatch[semv.Major][semv.Minor] = map[int]bool{} |
| } |
| majorMinorPatch[semv.Major][semv.Minor][semv.Patch] = true |
| } |
| |
| var possible []string |
| seen := map[string]bool{} |
| for _, v := range semVersions { |
| nextMajor := fmt.Sprintf("v%d.%d.%d", v.Major+1, 0, 0) |
| if _, ok := majorMinorPatch[v.Major+1]; !ok && !seen[nextMajor] { |
| seen[nextMajor] = true |
| possible = append(possible, nextMajor) |
| } |
| |
| nextMinor := fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor+1, 0) |
| if _, ok := majorMinorPatch[v.Major][v.Minor+1]; !ok && !seen[nextMinor] { |
| seen[nextMinor] = true |
| possible = append(possible, nextMinor) |
| } |
| |
| nextPatch := fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch+1) |
| if _, ok := majorMinorPatch[v.Major][v.Minor][v.Patch+1]; !ok && !seen[nextPatch] { |
| seen[nextPatch] = true |
| possible = append(possible, nextPatch) |
| } |
| } |
| |
| semver.Sort(possible) |
| return possible, nil |
| } |
| |
| // NewReleaseDefinition create a new workflow definition for gopls release. |
| func (r *ReleaseGoplsTasks) NewReleaseDefinition() *wf.Definition { |
| wd := wf.New(wf.ACL{Groups: []string{groups.ToolsTeam}}) |
| |
| // versionBumpStrategy specifies the desired release type: next minor, next |
| // patch or use explicit version. |
| // This should be the default choice for most releases. |
| versionBumpStrategy := wf.Param(wd, nextVersionParam) |
| // inputVersion allows manual override of the version, bypassing the version |
| // bump strategy. |
| // Use with caution. |
| inputVersion := wf.Param(wd, wf.ParamDef[string]{Name: "explicit pre-release version (optional)"}) |
| reviewers := wf.Param(wd, reviewersParam) |
| |
| release := wf.Task2(wd, "determine the release version", r.determineReleaseVersion, inputVersion, versionBumpStrategy) |
| prerelease := wf.Task1(wd, "find the latest pre-release version", r.latestPrerelease, release) |
| approved := wf.Action2(wd, "wait for release coordinator approval", r.approveRelease, release, prerelease) |
| |
| tagged := wf.Action2(wd, "tag the release", r.tagRelease, release, prerelease, wf.After(approved)) |
| |
| issue := wf.Task2(wd, "find release git issue", r.findOrCreateGitHubIssue, release, wf.Const(false)) |
| changeID := wf.Task3(wd, "updating x/tools dependency in master branch in gopls sub dir", r.updateDependencyIfMinor, reviewers, release, issue, wf.After(tagged)) |
| _ = wf.Task1(wd, "await x/tools gopls dependency CL submission in gopls sub dir", clAwaiter{r.Gerrit}.awaitSubmission, changeID) |
| |
| vscodeGoChanges := wf.Task4(wd, "update gopls version in vscode-go", r.updateVSCodeGoGoplsVersion, reviewers, issue, release, wf.Const(""), wf.After(tagged)) |
| _ = wf.Task1(wd, "await gopls version update CLs submission in vscode-go", clAwaiter{r.Gerrit}.awaitSubmissions, vscodeGoChanges) |
| |
| return wd |
| } |
| |
| func (r *ReleaseGoplsTasks) latestPrerelease(ctx *wf.TaskContext, semv semversion) (string, error) { |
| tags, err := r.Gerrit.ListTags(ctx, "tools") |
| if err != nil { |
| return "", err |
| } |
| |
| var versions []string |
| for _, tag := range tags { |
| if v, ok := strings.CutPrefix(tag, "gopls/"); ok { |
| versions = append(versions, v) |
| } |
| } |
| |
| rc := latestVersion(versions, isSameMajorMinorPatch(semv), isPrereleaseMatchRegex(`^pre\.\d+$`)) |
| if rc == (semversion{}) { |
| return "", fmt.Errorf("could not find any release candidate for v%v.%v.%v", semv.Major, semv.Minor, semv.Patch) |
| } |
| |
| return rc.Pre, nil |
| } |
| |
| // updateVSCodeGoGoplsVersion updates the gopls version in the vscode-go project. |
| // For releases (input param prerelease is empty), it updates both the master |
| // and release branches. |
| // For pre-releases (input param prerelease is not empty), it updates only the |
| // master branch. |
| func (r *ReleaseGoplsTasks) updateVSCodeGoGoplsVersion(ctx *wf.TaskContext, reviewers []string, issue int64, release semversion, prerelease string) ([]string, error) { |
| version := fmt.Sprintf("v%v.%v.%v", release.Major, release.Minor, release.Patch) |
| if prerelease != "" { |
| version = version + "-" + prerelease |
| } |
| branches := []string{"master"} |
| if prerelease == "" { |
| releaseBranch, err := vsCodeGoActiveReleaseBranch(ctx, r.Gerrit) |
| if err != nil { |
| return nil, err |
| } |
| branches = append(branches, releaseBranch) |
| } |
| |
| var cls []string |
| for _, branch := range branches { |
| clTitle := fmt.Sprintf(`extension/src/goToolsInformation: update gopls version %s`, version) |
| if branch != "master" { |
| clTitle = "[" + branch + "] " + clTitle |
| } |
| openCL, err := openCL(ctx, r.Gerrit, "vscode-go", branch, clTitle) |
| if err != nil { |
| return nil, fmt.Errorf("failed to find the open CL of title %q in branch %q: %w", clTitle, branch, err) |
| } |
| if openCL != "" { |
| ctx.Printf("not creating CL: found existing CL %s", openCL) |
| cls = append(cls, openCL) |
| continue |
| } |
| const script = `go run -C extension tools/generate.go -tools` |
| changedFiles, err := executeAndMonitorChange(ctx, r.CloudBuild, "vscode-go", branch, script, []string{"extension/src/goToolsInformation.ts"}) |
| if err != nil { |
| return nil, err |
| } |
| |
| // Skip CL creation as nothing changed. |
| if len(changedFiles) == 0 { |
| continue |
| } |
| |
| changeInput := gerrit.ChangeInput{ |
| Project: "vscode-go", |
| Branch: branch, |
| Subject: fmt.Sprintf("%s\n\nThis is an automated CL which updates the gopls version.\n\nFor golang/go#%v", clTitle, issue), |
| } |
| |
| cl, err := r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, changedFiles) |
| if err != nil { |
| return nil, err |
| } |
| ctx.Printf("created auto-submit change %s under branch %q in vscode-go.", cl, branch) |
| cls = append(cls, cl) |
| } |
| return cls, nil |
| } |
| |
| // tagRelease locates the commit associated with the pre-release version and |
| // applies the official release tag in form of "gopls/vX.Y.Z" to the same commit. |
| func (r *ReleaseGoplsTasks) tagRelease(ctx *wf.TaskContext, semv semversion, prerelease string) error { |
| info, err := r.Gerrit.GetTag(ctx, "tools", fmt.Sprintf("gopls/v%v.%v.%v-%s", semv.Major, semv.Minor, semv.Patch, prerelease)) |
| if err != nil { |
| return err |
| } |
| |
| // Defensively guard against re-creating tags. |
| ctx.DisableRetries() |
| |
| releaseTag := fmt.Sprintf("gopls/v%v.%v.%v", semv.Major, semv.Minor, semv.Patch) |
| if err := r.Gerrit.Tag(ctx, "tools", releaseTag, info.Revision); err != nil { |
| return err |
| } |
| |
| ctx.Printf("tagged commit %s with tag %s", info.Revision, releaseTag) |
| return nil |
| } |
| |
| // updateDependencyIfMinor update the dependency of x/tools repo in master |
| // branch. |
| // |
| // Returns the change ID. |
| func (r *ReleaseGoplsTasks) updateDependencyIfMinor(ctx *wf.TaskContext, reviewers []string, semv semversion, issue int64) (string, error) { |
| if semv.Patch != 0 { |
| return "", nil |
| } |
| |
| clTitle := fmt.Sprintf("gopls/go.mod: update dependencies following the v%v.%v.%v release", semv.Major, semv.Minor, semv.Patch) |
| openCL, err := openCL(ctx, r.Gerrit, "tools", "master", clTitle) |
| if err != nil { |
| return "", fmt.Errorf("failed to find the open CL of title %q in master branch: %w", clTitle, err) |
| } |
| if openCL != "" { |
| ctx.Printf("not creating CL: found existing CL %s", openCL) |
| return openCL, nil |
| } |
| |
| // TODO(hxjiang): Remove -compat flag once gopls no longer supports building |
| // with older Go versions. |
| const script = `cd gopls |
| pwd |
| go get -u all |
| go mod tidy -compat=1.19 |
| ` |
| changed, err := executeAndMonitorChange(ctx, r.CloudBuild, "tools", "master", script, []string{"gopls/go.mod", "gopls/go.sum"}) |
| if err != nil { |
| return "", err |
| } |
| |
| // Skip CL creation as nothing changed. |
| if len(changed) == 0 { |
| return "", nil |
| } |
| |
| changeInput := gerrit.ChangeInput{ |
| Project: "tools", |
| Branch: "master", |
| Subject: fmt.Sprintf("%s\n\nThis is an automated CL which updates the go.mod and go.sum.\n\nFor golang/go#%v", clTitle, issue), |
| } |
| |
| ctx.Printf("creating auto-submit change under master branch in x/tools repo.") |
| return r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, changed) |
| } |
| |
| // executeAndMonitorChange runs the specified script on the designated branch, |
| // tracking changes to the provided files. |
| // |
| // Returns a map where keys are the filenames of modified files and values are |
| // their corresponding content after script execution. |
| func executeAndMonitorChange(ctx *wf.TaskContext, cloudBuild CloudBuildClient, project, branch, script string, watchFiles []string) (map[string]string, error) { |
| // Checkout to the provided branch. |
| fullScript := fmt.Sprintf(`git checkout %s |
| git rev-parse --abbrev-ref HEAD |
| git rev-parse --ref HEAD |
| `, branch) |
| // Make a copy of all file that need to watch. |
| // If the file does not exist, create a empty file and a empty before file. |
| for _, file := range watchFiles { |
| if strings.Contains(file, "'") { |
| return nil, fmt.Errorf("file name %q contains '", file) |
| } |
| fullScript += fmt.Sprintf(`if [ -f '%[1]s' ]; then |
| cp '%[1]s' '%[1]s.before' |
| else |
| touch '%[1]s' '%[1]s.before' |
| fi |
| `, file) |
| } |
| // Execute the script provided. |
| fullScript += script |
| |
| // Output files before the script execution and after the script execution. |
| outputFiles := []string{} |
| for _, file := range watchFiles { |
| outputFiles = append(outputFiles, file+".before") |
| outputFiles = append(outputFiles, file) |
| } |
| build, err := cloudBuild.RunScript(ctx, fullScript, project, outputFiles) |
| if err != nil { |
| return nil, err |
| } |
| |
| outputs, err := buildToOutputs(ctx, cloudBuild, build) |
| if err != nil { |
| return nil, err |
| } |
| |
| changed := map[string]string{} |
| for i := 0; i < len(outputFiles); i += 2 { |
| if before, after := outputs[outputFiles[i]], outputs[outputFiles[i+1]]; before != after { |
| changed[outputFiles[i+1]] = after |
| } |
| } |
| |
| return changed, nil |
| } |
| |
| // A clAwaiter closes over a GerritClient to provide a reusable workflow task |
| // for awaiting the submission of a Gerrit change. |
| type clAwaiter struct { |
| GerritClient |
| } |
| |
| // awaitSubmission waits for the specified change to be submitted, then returns |
| // the corresponding commit hash. |
| func (c clAwaiter) awaitSubmission(ctx *wf.TaskContext, changeID string) (string, error) { |
| if changeID == "" { |
| ctx.Printf("not awaiting: no CL was created") |
| return "", nil |
| } |
| |
| ctx.Printf("awaiting review/submit of %v", ChangeLink(changeID)) |
| return AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) { |
| return c.Submitted(ctx, changeID, "") |
| }) |
| } |
| |
| // awaitSubmissions waits for the specified changes to be submitted, then |
| // returns a slice of commit hashes corresponding to the input change IDs, |
| // maintaining the original input order. |
| func (c clAwaiter) awaitSubmissions(ctx *wf.TaskContext, changeIDs []string) ([]string, error) { |
| if len(changeIDs) == 0 { |
| ctx.Printf("not awaiting: no CL was created") |
| return nil, nil |
| } |
| |
| var commits []string |
| for _, changeID := range changeIDs { |
| commit, err := c.awaitSubmission(ctx, changeID) |
| if err != nil { |
| return nil, err |
| } |
| commits = append(commits, commit) |
| } |
| |
| return commits, nil |
| } |