Skip to content

Commit 9005598

Browse files
rolandshoemakergopherbot
authored andcommitted
internal/task: add a workflow for publishing private x/ patches
This adds a workflow to relui which takes a patch from the private internal gerrit instance for one of the golang.org/x/ repos and sends it to the public gerrit, waits for it to be submitted, tags the repo, and emails an announcement message to the various lists. Requires a minor change to the HTML workflow template to allow for non-slice textareas. Updates golang/go#65756 Change-Id: Ica1ec5982545ddd7fff1e71bd33eb3281572017d Reviewed-on: https://go-review.googlesource.com/c/build/+/559295 LUCI-TryBot-Result: Go LUCI <[email protected]> Auto-Submit: Roland Shoemaker <[email protected]> Reviewed-by: Dmitri Shuralyov <[email protected]> Reviewed-by: Dmitri Shuralyov <[email protected]>
1 parent 4a5e2a9 commit 9005598

File tree

8 files changed

+418
-12
lines changed

8 files changed

+418
-12
lines changed

cmd/relui/main.go

+12
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,18 @@ func main() {
307307
}
308308
dh.RegisterDefinition("Sync go-private master branch with public", privateSyncTask.NewDefinition())
309309

310+
privateXPatchTask := &task.PrivXPatch{
311+
PublicGerrit: gerritClient,
312+
PrivateGerrit: privateGerritClient,
313+
PublicRepoURL: func(repo string) string {
314+
return "https://go.googlesource.com/" + repo
315+
},
316+
ApproveAction: relui.ApproveActionDep(dbPool),
317+
SendMail: mailFunc,
318+
AnnounceMailHeader: annMail,
319+
}
320+
dh.RegisterDefinition("Publish a private patch to a x/ repo", privateXPatchTask.NewDefinition(tagTasks))
321+
310322
var base *url.URL
311323
if *baseURL != "" {
312324
base, err = url.Parse(*baseURL)

internal/relui/static/styles.css

+5
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,11 @@ h6 {
229229
.NewWorkflow-parameter--string input {
230230
flex-grow: 1;
231231
}
232+
.NewWorkflow-parameter--string textarea {
233+
font-family: inherit;
234+
height: 4rem;
235+
width: 100%;
236+
}
232237
.NewWorkflow-parameter--slice textarea {
233238
font-family: inherit;
234239
height: 4rem;

internal/relui/templates/new_workflow.html

+19-11
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,6 @@ <h2>New Go Release</h2>
8383
{{end}}
8484
</select>
8585
</div>
86-
{{else if or (eq $p.Type.String "string") (eq $p.Type.String "task.Date")}}
87-
<div class="NewWorkflow-parameter NewWorkflow-parameter--{{$p.Type.String}}">
88-
<label for="workflow.params.{{$p.Name}}" title="{{$p.Doc}}">{{$p.Name}}</label>
89-
<input
90-
id="workflow.params.{{$p.Name}}"
91-
name="workflow.params.{{$p.Name}}"
92-
{{- with $p.HTMLInputType}}type="{{.}}"{{end}}
93-
{{- if $p.RequireNonZero}}required{{end}}
94-
placeholder="{{$p.Example}}" />
95-
</div>
9686
{{else if eq $p.Type.String "[]string"}}
9787
<div class="NewWorkflow-parameter NewWorkflow-parameter--slice">
9888
<div class="NewWorkflow-parameterRow">
@@ -109,7 +99,25 @@ <h2>New Go Release</h2>
10999
</button>
110100
</div>
111101
</div>
112-
{{else if eq $p.Type.String "bool"}}
102+
{{else if eq $p.HTMLElement "textarea"}}
103+
<div class="NewWorkflow-parameter NewWorkflow-parameter--{{$p.Type.String}}">
104+
<label for="workflow.params.{{$p.Name}}" title="{{$p.Doc}}">{{$p.Name}}</label>
105+
<textarea
106+
id="workflow.params.{{$p.Name}}"
107+
name="workflow.params.{{$p.Name}}"
108+
placeholder="{{$p.Example}}"></textarea>
109+
</div>
110+
{{else if or (eq $p.Type.String "string") (eq $p.Type.String "task.Date")}}
111+
<div class="NewWorkflow-parameter NewWorkflow-parameter--{{$p.Type.String}}">
112+
<label for="workflow.params.{{$p.Name}}" title="{{$p.Doc}}">{{$p.Name}}</label>
113+
<input
114+
id="workflow.params.{{$p.Name}}"
115+
name="workflow.params.{{$p.Name}}"
116+
{{- with $p.HTMLInputType}}type="{{.}}"{{end}}
117+
{{- if $p.RequireNonZero}}required{{end}}
118+
placeholder="{{$p.Example}}" />
119+
</div>
120+
{{else if eq $p.Type.String "bool"}}
113121
<div class="NewWorkflow-parameter NewWorkflow-parameter--bool">
114122
<label for="workflow.params.{{$p.Name}}" title="{{$p.Doc}}">{{$p.Name}}</label>
115123
<input

internal/task/fakes.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,15 @@ func NewFakeRepo(t *testing.T, name string) *FakeRepo {
118118
t.Skip("test requires git")
119119
}
120120

121+
tmpDir := t.TempDir()
122+
repoDir := filepath.Join(tmpDir, name)
123+
if err := os.Mkdir(repoDir, 0700); err != nil {
124+
t.Fatalf("failed to create repository directory: %s", err)
125+
}
121126
r := &FakeRepo{
122127
t: t,
123128
name: name,
124-
dir: &GitDir{&Git{}, t.TempDir()},
129+
dir: &GitDir{&Git{}, repoDir},
125130
}
126131
t.Cleanup(func() { r.dir.Close() })
127132
r.runGit("init")
@@ -330,6 +335,10 @@ func (*FakeGerrit) SetHashtags(_ context.Context, changeID string, _ gerrit.Hash
330335
return fmt.Errorf("pretend that SetHashtags failed")
331336
}
332337

338+
func (*FakeGerrit) GetChange(_ context.Context, _ string, _ ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) {
339+
return nil, nil
340+
}
341+
333342
// NewFakeSignService returns a fake signing service that can sign PKGs, MSIs,
334343
// and generate GPG signatures. MSIs are "signed" by adding a suffix to them.
335344
// PKGs must actually be tarballs with a prefix of "I'm a PKG!\n". Any files

internal/task/gerrit.go

+6
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ type GerritClient interface {
4949
QueryChanges(ctx context.Context, query string) ([]*gerrit.ChangeInfo, error)
5050
// SetHashtags modifies the hashtags for a CL.
5151
SetHashtags(ctx context.Context, changeID string, hashtags gerrit.HashtagsInput) error
52+
// GetChange gets information about a specific change.
53+
GetChange(ctx context.Context, changeID string, opts ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error)
5254
}
5355

5456
type RealGerritClient struct {
@@ -226,6 +228,10 @@ func (c *RealGerritClient) QueryChanges(ctx context.Context, query string) ([]*g
226228
return c.Client.QueryChanges(ctx, query)
227229
}
228230

231+
func (c *RealGerritClient) GetChange(ctx context.Context, changeID string, opts ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) {
232+
return c.Client.GetChange(ctx, changeID, opts...)
233+
}
234+
229235
func (c *RealGerritClient) SetHashtags(ctx context.Context, changeID string, hashtags gerrit.HashtagsInput) error {
230236
_, err := c.Client.SetHashtags(ctx, changeID, hashtags)
231237
return err

internal/task/privx.go

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

0 commit comments

Comments
 (0)