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`))