Skip to content

Commit

Permalink
cmd/relui: add pre-announcement workflows
Browse files Browse the repository at this point in the history
These workflows do the work of filling in a pre-announcement template
with the user-provided values, taking ownership over email formatting,
and sending it to the right mailing lists.

Add a new parameter type to relui for convenience of entering a date.
It takes advantage of the <input type="date"> element available in
modern browsers.

Also add a select parameter type that allows selecting from a known
set of options, to avoid needing to type them manually.

Fixes golang/go#54063.

Change-Id: I041c2659db6bd384f3850b2df3bd31c8b096579d
Reviewed-on: https://go-review.googlesource.com/c/build/+/425196
Reviewed-by: Heschi Kreinick <[email protected]>
TryBot-Result: Gopher Robot <[email protected]>
Run-TryBot: Dmitri Shuralyov <[email protected]>
Auto-Submit: Dmitri Shuralyov <[email protected]>
Reviewed-by: Dmitri Shuralyov <[email protected]>
  • Loading branch information
dmitshur authored and gopherbot committed Aug 25, 2022
1 parent 1d544f3 commit 8424680
Show file tree
Hide file tree
Showing 14 changed files with 400 additions and 54 deletions.
6 changes: 3 additions & 3 deletions cmd/relui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ func main() {
}
sendgridAPIKey := secret.Flag("sendgrid-api-key", "SendGrid API key for workflows involving sending email.")
var annMail task.MailHeader
addressVarFlag(&annMail.From, "announce-mail-from", "The From address to use for the announcement mail.")
addressVarFlag(&annMail.To, "announce-mail-to", "The To address to use for the announcement mail.")
addressListVarFlag(&annMail.BCC, "announce-mail-bcc", "The BCC address list to use for the announcement mail.")
addressVarFlag(&annMail.From, "announce-mail-from", "The From address to use for the (pre-)announcement mail.")
addressVarFlag(&annMail.To, "announce-mail-to", "The To address to use for the (pre-)announcement mail.")
addressListVarFlag(&annMail.BCC, "announce-mail-bcc", "The BCC address list to use for the (pre-)announcement mail.")
var twitterAPI secret.TwitterCredentials
secret.JSONVarFlag(&twitterAPI, "twitter-api-secret", "Twitter API secret to use for workflows involving tweeting.")
masterKey := secret.Flag("builder-master-key", "Builder master key")
Expand Down
1 change: 1 addition & 0 deletions internal/gophers/gophers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2640,6 +2640,7 @@ func init() {
addPerson("Taro Aoki", "[email protected]", "@ktr0731")
addPerson("Tarrant", "[email protected]", "@tarrant")
addPerson("Taru Karttunen", "[email protected]", "@taruti")
addPerson("Tatiana Bradley", "[email protected]")
addPerson("Tatsuhiro Tsujikawa", "[email protected]", "@tatsuhiro-t")
addPerson("Taufiq Rahman", "[email protected]", "@Inconnu08")
addPerson("Ted Hahn", "[email protected]")
Expand Down
15 changes: 13 additions & 2 deletions internal/relui/templates/new_workflow.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,19 @@ <h2>New Go Release</h2>
<form action="{{baseLink "/workflows"}}" method="post">
<input type="hidden" id="workflow.name" name="workflow.name" value="{{$.Name}}" />
{{range $_, $p := .Selected.Parameters}}
{{if eq $p.Type.String "string"}}
<div class="NewWorkflow-parameter NewWorkflow-parameter--string">
{{if eq $p.HTMLElement "select"}}
<div class="NewWorkflow-parameter NewWorkflow-parameter--select">
<label for="workflow.params.{{$p.Name}}" title="{{$p.Doc}}">{{$p.Name}}</label>
<select id="workflow.params.{{$p.Name}}" name="workflow.params.{{$p.Name}}"
{{- if $p.RequireNonZero}} required{{end}}>
<option></option>
{{range $_, $name := $p.HTMLSelectOptions}}
<option value="{{$name}}">{{$name}}</option>
{{end}}
</select>
</div>
{{else if or (eq $p.Type.String "string") (eq $p.Type.String "task.Date")}}
<div class="NewWorkflow-parameter NewWorkflow-parameter--{{$p.Type.String}}">
<label for="workflow.params.{{$p.Name}}" title="{{$p.Doc}}">{{$p.Name}}</label>
<input
id="workflow.params.{{$p.Name}}"
Expand Down
11 changes: 11 additions & 0 deletions internal/relui/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/julienschmidt/httprouter"
"golang.org/x/build/internal/metrics"
"golang.org/x/build/internal/relui/db"
"golang.org/x/build/internal/task"
"golang.org/x/build/internal/workflow"
)

Expand Down Expand Up @@ -335,6 +336,16 @@ func (s *Server) createWorkflowHandler(w http.ResponseWriter, r *http.Request) {
return
}
params[p.Name()] = v
case "task.Date":
v, err := time.Parse("2006-01-02", r.FormValue(fmt.Sprintf("workflow.params.%s", p.Name())))
if err != nil {
http.Error(w, fmt.Sprintf("parameter %q parsing error: %v", p.Name(), err), http.StatusBadRequest)
return
} else if p.RequireNonZero() && v.IsZero() {
http.Error(w, fmt.Sprintf("parameter %q must have non-zero value", p.Name()), http.StatusBadRequest)
return
}
params[p.Name()] = task.Date{Year: v.Year(), Month: v.Month(), Day: v.Day()}
default:
http.Error(w, fmt.Sprintf("parameter %q has an unsupported type %q", p.Name(), p.Type()), http.StatusInternalServerError)
return
Expand Down
51 changes: 50 additions & 1 deletion internal/relui/workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"golang.org/x/build/internal/releasetargets"
"golang.org/x/build/internal/relui/db"
"golang.org/x/build/internal/task"
"golang.org/x/build/internal/workflow"
wf "golang.org/x/build/internal/workflow"
"golang.org/x/net/context/ctxhttp"
)
Expand Down Expand Up @@ -133,6 +134,31 @@ func RegisterCommunicationDefinitions(h *DefinitionHolder, tasks task.Communicat

// Release parameter definitions.
var (
targetDateParam = wf.ParamDef[task.Date]{
Name: "Target Release Date",
ParamType: wf.ParamType[task.Date]{
HTMLElement: "input",
HTMLInputType: "date",
},
Doc: `Target Release Date is the date on which the release is scheduled.
It must be three to seven days after the pre-announcement as documented in the security policy.`,
}
securityPreAnnParam = wf.ParamDef[string]{
Name: "Security Content",
ParamType: workflow.ParamType[string]{
HTMLElement: "select",
HTMLSelectOptions: []string{
"the standard library",
"the toolchain",
"the standard library and the toolchain",
},
},
Doc: `Security Content is the security content to be included in the release pre-announcement.
It must not reveal details beyond what's allowed by the security policy.`,
}

securitySummaryParameter = wf.ParamDef[string]{
Name: "Security Summary (optional)",
Doc: `Security Summary is an optional sentence describing security fixes included in this release.
Expand Down Expand Up @@ -259,11 +285,34 @@ func RegisterReleaseWorkflows(ctx context.Context, h *DefinitionHolder, build *B
return err
}

// Register dry-run release workflows.
// Register pre-announcement workflows.
currentMajor, err := version.GetCurrentMajor(ctx)
if err != nil {
return err
}
releases := []struct {
kinds []task.ReleaseKind
name string
}{
{[]task.ReleaseKind{task.KindCurrentMinor, task.KindPrevMinor}, fmt.Sprintf("next minor release for Go 1.%d and 1.%d", currentMajor, currentMajor-1)},
{[]task.ReleaseKind{task.KindCurrentMinor}, fmt.Sprintf("next minor release for Go 1.%d", currentMajor)},
{[]task.ReleaseKind{task.KindPrevMinor}, fmt.Sprintf("next minor release for Go 1.%d", currentMajor-1)},
}
for _, r := range releases {
wd := wf.New()

versions := wf.Task1(wd, "Get next versions", version.GetNextVersions, wf.Const(r.kinds))
targetDate := wf.Param(wd, targetDateParam)
securityContent := wf.Param(wd, securityPreAnnParam)
coordinators := wf.Param(wd, releaseCoordinators)

sentMail := wf.Task4(wd, "mail-pre-announcement", comm.PreAnnounceRelease, versions, targetDate, securityContent, coordinators)
wf.Output(wd, "Pre-announcement URL", wf.Task1(wd, "await-pre-announcement", comm.AwaitAnnounceMail, sentMail))

h.RegisterDefinition("pre-announce "+r.name, wd)
}

// Register dry-run release workflows.
wd := wf.New()
if err := addBuildAndTestOnlyWorkflow(wd, version, build, currentMajor+1, task.KindBeta); err != nil {
return err
Expand Down
180 changes: 150 additions & 30 deletions internal/task/announce.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,50 @@ type releaseAnnouncement struct {
Names []string
}

// AnnounceMailTasks contains tasks related to the release announcement email.
type releasePreAnnouncement struct {
// Target is the planned date for the release.
Target Date

// Version is the Go version that will be released.
//
// The version string must use the same format as Go tags. For example, "go1.17.2".
Version string
// SecondaryVersion is an older Go version that will also be released.
// This only applies when two releases are planned. For example, "go1.16.10".
SecondaryVersion string

// Security is the security content to be included in the
// release pre-announcement. It should not reveal details
// beyond what's allowed by the security policy.
Security string

// Names is an optional list of release coordinator names to
// include in the sign-off message.
Names []string
}

// A Date represents a single calendar day (year, month, day).
//
// This type does not include location information, and
// therefore does not describe a unique 24-hour timespan.
//
// TODO(go.dev/issue/19700): Start using time.Day or so when available.
type Date struct {
Year int // Year (for example, 2009).
Month time.Month // Month of the year (January = 1, ...).
Day int // Day of the month, starting at 1.
}

func (d Date) String() string { return d.Format("2006-01-02") }
func (d Date) Format(layout string) string {
return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, time.UTC).Format(layout)
}
func (d Date) After(year int, month time.Month, day int) bool {
return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, time.UTC).
After(time.Date(year, month, day, 0, 0, 0, 0, time.UTC))
}

// AnnounceMailTasks contains tasks related to the release (pre-)announcement email.
type AnnounceMailTasks struct {
// SendMail sends an email with the given header and content
// using an externally-provided implementation.
Expand All @@ -68,8 +111,11 @@ type AnnounceMailTasks struct {
// doesn't indicate anything about the status of the delivery.
SendMail func(MailHeader, mailContent) error

// AnnounceMailHeader is the header to use for the release announcement email.
// AnnounceMailHeader is the header to use for the release (pre-)announcement email.
AnnounceMailHeader MailHeader

// testHookNow is optionally set by tests to override time.Now.
testHookNow func() time.Time
}

// SentMail represents an email that was sent.
Expand Down Expand Up @@ -137,6 +183,70 @@ func (t AnnounceMailTasks) AnnounceRelease(ctx *workflow.TaskContext, versions [
return SentMail{m.Subject}, nil
}

// PreAnnounceRelease sends an email pre-announcing a Go release
// containing PRIVATE track security fixes planned for the target date.
func (t AnnounceMailTasks) PreAnnounceRelease(ctx *workflow.TaskContext, versions []string, target Date, security string, users []string) (SentMail, error) {
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < time.Minute {
return SentMail{}, fmt.Errorf("insufficient time for pre-announce release task; a minimum of a minute left on context is required")
}
if err := oneOrTwoGoVersions(versions); err != nil {
return SentMail{}, err
}
now := time.Now().UTC()
if t.testHookNow != nil {
now = t.testHookNow()
}
if !target.After(now.Year(), now.Month(), now.Day()) { // A very simple check. Improve as needed.
return SentMail{}, fmt.Errorf("target release date is not in the future")
}
if security == "" {
return SentMail{}, fmt.Errorf("security content is not specified")
}
names, err := coordinatorFirstNames(users)
if err != nil {
return SentMail{}, err
}

r := releasePreAnnouncement{
Target: target,
Version: versions[0],
Security: security,
Names: names,
}
if len(versions) == 2 {
r.SecondaryVersion = versions[1]
}

// Generate the pre-announcement email.
m, err := announcementMail(r)
if err != nil {
return SentMail{}, err
}
ctx.Printf("pre-announcement subject: %s\n\n", m.Subject)
ctx.Printf("pre-announcement body HTML:\n%s\n", m.BodyHTML)
ctx.Printf("pre-announcement body text:\n%s", m.BodyText)

// Before sending, check to see if this pre-announcement already exists.
if threadURL, err := findGoogleGroupsThread(ctx, m.Subject); err != nil {
return SentMail{}, fmt.Errorf("stopping early due to error checking for an existing Google Groups thread: %v", err)
} else if threadURL != "" {
ctx.Printf("a Google Groups thread with matching subject %q already exists at %q, so we'll consider that as it being sent successfully", m.Subject, threadURL)
return SentMail{m.Subject}, nil
}

// Send the pre-announcement email to the destination mailing lists.
if t.SendMail == nil {
return SentMail{Subject: "[dry-run] " + m.Subject}, nil
}
ctx.DisableRetries()
err = t.SendMail(t.AnnounceMailHeader, m)
if err != nil {
return SentMail{}, err
}

return SentMail{m.Subject}, nil
}

func coordinatorFirstNames(users []string) ([]string, error) {
return mapCoordinators(users, func(p *gophers.Person) string {
name, _, _ := strings.Cut(p.Name, " ")
Expand Down Expand Up @@ -179,39 +289,49 @@ type mailContent struct {
BodyText string
}

// announcementMail generates the announcement email for release r.
func announcementMail(r releaseAnnouncement) (mailContent, error) {
// Pick a template name for this type of release.
// announcementMail generates the (pre-)announcement email using data,
// which must be one of these types:
// - releaseAnnouncement for a release announcement
// - releasePreAnnouncement for a release pre-announcement
func announcementMail(data any) (mailContent, error) {
// Select the appropriate template name.
var name string
if i := strings.Index(r.Version, "beta"); i != -1 { // A beta release.
name = "announce-beta.md"
} else if i := strings.Index(r.Version, "rc"); i != -1 { // Release Candidate.
name = "announce-rc.md"
} else if strings.Count(r.Version, ".") == 1 { // Major release like "go1.X".
name = "announce-major.md"
} else if strings.Count(r.Version, ".") == 2 { // Minor release like "go1.X.Y".
name = "announce-minor.md"
} else {
return mailContent{}, fmt.Errorf("unknown version format: %q", r.Version)
}

if len(r.Security) > 0 && name != "announce-minor.md" {
// The Security field isn't supported in templates other than minor,
// so report an error instead of silently dropping it.
//
// Note: Maybe in the future we'd want to consider support for including sentences like
// "This beta release includes the same security fixes as in Go X.Y.Z and Go A.B.C.",
// but we'll have a better idea after these initial templates get more practical use.
return mailContent{}, fmt.Errorf("email template %q doesn't support the Security field; this field can only be used in minor releases", name)
} else if r.SecondaryVersion != "" && name != "announce-minor.md" {
return mailContent{}, fmt.Errorf("email template %q doesn't support more than one release; the SecondaryVersion field can only be used in minor releases", name)
switch r := data.(type) {
case releaseAnnouncement:
if i := strings.Index(r.Version, "beta"); i != -1 { // A beta release.
name = "announce-beta.md"
} else if i := strings.Index(r.Version, "rc"); i != -1 { // Release Candidate.
name = "announce-rc.md"
} else if strings.Count(r.Version, ".") == 1 { // Major release like "go1.X".
name = "announce-major.md"
} else if strings.Count(r.Version, ".") == 2 { // Minor release like "go1.X.Y".
name = "announce-minor.md"
} else {
return mailContent{}, fmt.Errorf("unknown version format: %q", r.Version)
}

if len(r.Security) > 0 && name != "announce-minor.md" {
// The Security field isn't supported in templates other than minor,
// so report an error instead of silently dropping it.
//
// Note: Maybe in the future we'd want to consider support for including sentences like
// "This beta release includes the same security fixes as in Go X.Y.Z and Go A.B.C.",
// but we'll have a better idea after these initial templates get more practical use.
return mailContent{}, fmt.Errorf("email template %q doesn't support the Security field; this field can only be used in minor releases", name)
} else if r.SecondaryVersion != "" && name != "announce-minor.md" {
return mailContent{}, fmt.Errorf("email template %q doesn't support more than one release; the SecondaryVersion field can only be used in minor releases", name)
}
case releasePreAnnouncement:
name = "pre-announce-minor.md"
default:
return mailContent{}, fmt.Errorf("unknown template data type %T", data)
}

// Render the announcement email template.
// Render the (pre-)announcement email template.
//
// It'll produce a valid message with a MIME header and a body, so parse it as such.
var buf bytes.Buffer
if err := announceTmpl.ExecuteTemplate(&buf, name, r); err != nil {
if err := announceTmpl.ExecuteTemplate(&buf, name, data); err != nil {
return mailContent{}, err
}
m, err := mail.ReadMessage(&buf)
Expand Down Expand Up @@ -292,7 +412,7 @@ var announceTmpl = template.Must(template.New("").Funcs(template.FuncMap{
}
return "", fmt.Errorf("internal error: unhandled pre-release Go version %q", v)
},
}).ParseFS(tmplDir, "template/announce-*.md"))
}).ParseFS(tmplDir, "template/announce-*.md", "template/pre-announce-minor.md"))

//go:embed template
var tmplDir embed.FS
Expand Down
Loading

0 comments on commit 8424680

Please sign in to comment.