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

     1  // Copyright 2024 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  	"bytes"
     9  	"errors"
    10  	"fmt"
    11  	"net/mail"
    12  	"regexp"
    13  	"slices"
    14  	"text/template"
    15  	"time"
    16  
    17  	"golang.org/x/build/gerrit"
    18  	wf "golang.org/x/build/internal/workflow"
    19  )
    20  
    21  type PrivXPatch struct {
    22  	Git           *Git
    23  	PublicGerrit  GerritClient
    24  	PrivateGerrit GerritClient
    25  	// PublicRepoURL returns a git clone URL for repo
    26  	PublicRepoURL func(repo string) string
    27  
    28  	ApproveAction      func(*wf.TaskContext) error
    29  	SendMail           func(MailHeader, MailContent) error
    30  	AnnounceMailHeader MailHeader
    31  }
    32  
    33  func (x *PrivXPatch) NewDefinition(tagx *TagXReposTasks) *wf.Definition {
    34  	wd := wf.New()
    35  	// TODO: this should be simpler, CL number + patchset?
    36  	clNumber := wf.Param(wd, wf.ParamDef[string]{Name: "go-internal CL number", Example: "536316"})
    37  	reviewers := wf.Param(wd, reviewersParam)
    38  	repoName := wf.Param(wd, wf.ParamDef[string]{Name: "Repository name", Example: "net"})
    39  	// TODO: probably always want to skip, might make sense to not include this
    40  	skipPostSubmit := wf.Param(wd, wf.ParamDef[bool]{Name: "Skip post submit result (optional)", ParamType: wf.Bool})
    41  	cve := wf.Param(wd, wf.ParamDef[string]{Name: "CVE"})
    42  	githubIssue := wf.Param(wd, wf.ParamDef[string]{Name: "GitHub issue", Doc: "The GitHub issue number of the report.", Example: "#12345"})
    43  	relNote := wf.Param(wd, wf.ParamDef[string]{Name: "Release note", ParamType: wf.LongString})
    44  	acknowledgement := wf.Param(wd, wf.ParamDef[string]{Name: "Acknowledgement"})
    45  
    46  	repos := wf.Task0(wd, "Load all repositories", tagx.SelectRepos)
    47  
    48  	repos = wf.Task4(wd, "Publish change", func(ctx *wf.TaskContext, clNumber string, reviewers []string, repos []TagRepo, repoName string) ([]TagRepo, error) {
    49  		if !slices.ContainsFunc(repos, func(r TagRepo) bool { return r.Name == repoName }) {
    50  			return nil, fmt.Errorf("no repository %q", repoName)
    51  		}
    52  
    53  		changeInfo, err := x.PrivateGerrit.GetChange(ctx, clNumber, gerrit.QueryChangesOpt{Fields: []string{"CURRENT_REVISION"}})
    54  		if err != nil {
    55  			return nil, err
    56  		}
    57  		if changeInfo.Project != repoName {
    58  			return nil, fmt.Errorf("CL is for unexpected project, got: %s, want %s", changeInfo.Project, repoName)
    59  		}
    60  		if changeInfo.Status != gerrit.ChangeStatusMerged {
    61  			return nil, fmt.Errorf("CL %s not merged, status is %s", clNumber, changeInfo.Status)
    62  		}
    63  		rev, ok := changeInfo.Revisions[changeInfo.CurrentRevision]
    64  		if !ok {
    65  			return nil, errors.New("current revision not found")
    66  		}
    67  		fetch, ok := rev.Fetch["http"]
    68  		if !ok {
    69  			return nil, errors.New("fetch info not found")
    70  		}
    71  		origin, ref := fetch.URL, fetch.Ref
    72  
    73  		// We directly use Git here, rather than the Gerrit API, as there are
    74  		// limitations to the types of patches which you can create using said
    75  		// API. In particular patches which contain any binary content are hard
    76  		// to replicate from one instance to another using the API alone. Rather
    77  		// than adding workarounds for those edge cases, we just use Git
    78  		// directly, which makes the process extremely simple.
    79  		repo, err := x.Git.Clone(ctx, x.PublicRepoURL(repoName))
    80  		if err != nil {
    81  			return nil, err
    82  		}
    83  		ctx.Printf("cloned repo into %s", repo.dir)
    84  
    85  		ctx.Printf("fetching %s from %s", ref, origin)
    86  		if _, err := repo.RunCommand(ctx.Context, "fetch", origin, ref); err != nil {
    87  			return nil, err
    88  		}
    89  		ctx.Printf("fetched")
    90  		if _, err := repo.RunCommand(ctx.Context, "cherry-pick", "FETCH_HEAD"); err != nil {
    91  			return nil, err
    92  		}
    93  		ctx.Printf("cherry-picked")
    94  		refspec := "HEAD:refs/for/master%l=Auto-Submit,l=Commit-Queue+1"
    95  		reviewerEmails, err := coordinatorEmails(reviewers)
    96  		if err != nil {
    97  			return nil, err
    98  		}
    99  		for _, reviewer := range reviewerEmails {
   100  			refspec += ",r=" + reviewer
   101  		}
   102  
   103  		// Beyond this point we don't want to retry any of the following steps.
   104  		ctx.DisableRetries()
   105  
   106  		ctx.Printf("pushing to %s", x.PublicRepoURL(repoName))
   107  		// We are unable to use repo.RunCommand here, because of strange i/o
   108  		// changes that git made. The messages sent by the remote are printed by
   109  		// git to stderr, and no matter what combination of options you pass it
   110  		// (--verbose, --porcelain, etc), you cannot reasonably convince it to
   111  		// print those messages to stdout. Because of this we need to use the
   112  		// underlying repo.git.runGitStreamed method, so that we can inspect
   113  		// stderr in order to extract the new CL number that gerrit sends us.
   114  		var stdout, stderr bytes.Buffer
   115  		err = repo.git.runGitStreamed(ctx.Context, &stdout, &stderr, repo.dir, "push", x.PublicRepoURL(repoName), refspec)
   116  		if err != nil {
   117  			return nil, fmt.Errorf("git push failed: %v, stdout: %q stderr: %q", err, stdout.String(), stderr.String())
   118  		}
   119  
   120  		// Extract the CL number from the output using a quick and dirty regex.
   121  		re, err := regexp.Compile(fmt.Sprintf(`https:\/\/go-review.googlesource.com\/c\/%s\/\+\/(\d+)`, regexp.QuoteMeta(repoName)))
   122  		if err != nil {
   123  			return nil, fmt.Errorf("failed to compile regex: %s", err)
   124  		}
   125  		matches := re.FindSubmatch(stderr.Bytes())
   126  		if len(matches) != 2 {
   127  			return nil, errors.New("unable to find CL number")
   128  		}
   129  		changeID := string(matches[1])
   130  
   131  		ctx.Printf("Awaiting review/submit of %v", changeID)
   132  		_, err = AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) {
   133  			return x.PublicGerrit.Submitted(ctx, changeID, "")
   134  		})
   135  		if err != nil {
   136  			return nil, err
   137  		}
   138  		return repos, nil
   139  	}, clNumber, reviewers, repos, repoName)
   140  
   141  	tagged := wf.Expand4(wd, "Create single-repo plan", tagx.BuildSingleRepoPlan, repos, repoName, skipPostSubmit, reviewers)
   142  
   143  	okayToAnnoucne := wf.Action0(wd, "Wait to Announce", x.ApproveAction, wf.After(tagged))
   144  
   145  	wf.Task5(wd, "Mail announcement", func(ctx *wf.TaskContext, tagged TagRepo, cve string, githubIssue string, relNote string, acknowledgement string) (string, error) {
   146  		var buf bytes.Buffer
   147  		if err := privXPatchAnnouncementTmpl.Execute(&buf, map[string]string{
   148  			"Module":          tagged.ModPath,
   149  			"Version":         tagged.NewerVersion,
   150  			"RelNote":         relNote,
   151  			"Acknowledgement": acknowledgement,
   152  			"CVE":             cve,
   153  			"GithubIssue":     githubIssue,
   154  		}); err != nil {
   155  			return "", err
   156  		}
   157  		m, err := mail.ReadMessage(&buf)
   158  		if err != nil {
   159  			return "", err
   160  		}
   161  		html, text, err := renderMarkdown(m.Body)
   162  		if err != nil {
   163  			return "", err
   164  		}
   165  
   166  		mc := MailContent{m.Header.Get("Subject"), html, text}
   167  
   168  		ctx.Printf("announcement subject: %s\n\n", mc.Subject)
   169  		ctx.Printf("announcement body HTML:\n%s\n", mc.BodyHTML)
   170  		ctx.Printf("announcement body text:\n%s", mc.BodyText)
   171  
   172  		ctx.DisableRetries()
   173  		err = x.SendMail(x.AnnounceMailHeader, mc)
   174  		if err != nil {
   175  			return "", err
   176  		}
   177  
   178  		return "", nil
   179  	}, tagged, cve, githubIssue, relNote, acknowledgement, wf.After(okayToAnnoucne))
   180  
   181  	wf.Output(wd, "done", tagged)
   182  	return wd
   183  }
   184  
   185  var privXPatchAnnouncementTmpl = template.Must(template.New("").Parse(`Subject: [security] Vulnerability in {{.Module}}
   186  
   187  Hello gophers,
   188  
   189  We have tagged version {{.Version}} of {{.Module}} in order to address a security issue.
   190  
   191  {{.RelNote}}
   192  
   193  Thanks to {{.Acknowledgement}} for reporting this issue.
   194  
   195  This is {{.CVE}} and Go issue {{.GithubIssue}}.
   196  
   197  Cheers,
   198  Go Security team`))