blob: 5edc5b2dc8e37af7d7854c52b0a9cf17d8388e68 [file] [log] [blame]
// Copyright 2021 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"math"
"math/rand"
"mime/multipart"
"net/http"
"strings"
"text/template"
"time"
"github.com/McKael/madon/v3"
"github.com/dghubble/oauth1"
"github.com/esimov/stackblur-go"
"golang.org/x/build/internal/secret"
"golang.org/x/build/internal/workflow"
"golang.org/x/build/maintner/maintnerd/maintapi/version"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gomono"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
// releaseTweet describes a tweet that announces a Go release.
type releaseTweet struct {
// Kind is the kind of release being announced.
Kind ReleaseKind
// Version is the Go version that has been released.
//
// The version string must use the same format as Go tags. For example:
// - "go1.21rc2" for a pre-release
// - "go1.21.0" for a major Go release
// - "go1.21.1" for a minor Go release
Version string
// SecondaryVersion is an older Go version that was also released.
// This only applies to minor releases when two releases are made.
// For example, "go1.20.9".
SecondaryVersion string
// Security is an optional sentence describing security fixes
// included in this release.
//
// The empty string means there are no security fixes to highlight.
// Past examples:
// - "Includes a security fix for the Wasm port (CVE-2021-38297)."
// - "Includes a security fix for archive/zip (CVE-2021-39293)."
// - "Includes a security fix for crypto/tls (CVE-2021-34558)."
// - "Includes security fixes for archive/zip, net, net/http/httputil, and math/big packages."
Security string
// Announcement is the announcement URL.
//
// It's applicable to all release types other than major,
// since major releases point to release notes instead.
// For example, "https://groups.google.com/g/golang-announce/c/wB1fph5RpsE/m/ZGwOsStwAwAJ".
Announcement string
}
type Poster interface {
// PostTweet posts a tweet with the given text and PNG image,
// both of which must be non-empty, and returns the tweet URL.
//
// ErrTweetTooLong error is returned if posting fails
// due to the tweet text length exceeding Twitter's limit.
PostTweet(text string, imagePNG []byte, altText string) (tweetURL string, _ error)
}
// SocialMediaTasks contains tasks related to the release tweet.
type SocialMediaTasks struct {
// TwitterClient can be used to post a tweet.
TwitterClient Poster
MastodonClient Poster
// RandomSeed is the pseudo-random number generator seed to use for presentational
// choices, such as selecting one out of many available emoji or release archives.
// The zero value means to use time.Now().UnixNano().
RandomSeed int64
}
func (t SocialMediaTasks) textAndImage(ctx *workflow.TaskContext, kind ReleaseKind, published []Published, security string, announcement string) (tweetText string, imagePNG []byte, imageText string, err error) {
if len(published) < 1 || len(published) > 2 {
return "", nil, "", fmt.Errorf("got %d published Go releases, TweetRelease supports only 1 or 2 at once", len(published))
}
r := releaseTweet{
Kind: kind,
Version: published[0].Version,
Security: security,
Announcement: announcement,
}
if len(published) == 2 {
r.SecondaryVersion = published[1].Version
}
seed := t.RandomSeed
if seed == 0 {
seed = time.Now().UnixNano()
}
rnd := rand.New(rand.NewSource(seed))
// Generate tweet text.
tweetText, err = r.tweetText(rnd)
if err != nil {
return "", nil, "", err
}
ctx.Printf("tweet text:\n%s\n", tweetText)
// Generate tweet image.
imagePNG, imageText, err = tweetImage(published[0], rnd)
if err != nil {
return "", nil, "", err
}
ctx.Printf("tweet image:\n%s\n", imageText)
return tweetText, imagePNG, imageText, nil
}
// TweetRelease posts a tweet announcing that a Go release has been published.
// ErrTweetTooLong is returned if the inputs result in a tweet that's too long.
func (t SocialMediaTasks) TweetRelease(ctx *workflow.TaskContext, kind ReleaseKind, published []Published, security string, announcement string) (_ string, _ error) {
tweetText, imagePNG, imageText, err := t.textAndImage(ctx, kind, published, security, announcement)
if err != nil {
return "", err
}
// Post a tweet via the Twitter API.
if t.TwitterClient == nil {
return "(dry-run)", nil
}
ctx.DisableRetries()
return t.TwitterClient.PostTweet(tweetText, imagePNG, imageText)
}
// TrumpetRelease posts a tweet announcing that a Go release has been published.
func (t SocialMediaTasks) TrumpetRelease(ctx *workflow.TaskContext, kind ReleaseKind, published []Published, security string, announcement string) (_ string, _ error) {
tweetText, imagePNG, imageText, err := t.textAndImage(ctx, kind, published, security, announcement)
if err != nil {
return "", err
}
// Post via the Mastodon API.
if t.MastodonClient == nil {
return "(dry-run)", nil
}
ctx.DisableRetries()
return t.MastodonClient.PostTweet(tweetText, imagePNG, imageText)
}
// tweetText generates the text to use in the announcement
// tweet for release r.
func (r *releaseTweet) tweetText(rnd *rand.Rand) (string, error) {
// Parse the tweet text template
// using rnd for emoji selection.
t, err := template.New("").Funcs(template.FuncMap{
"emoji": func(category string) (string, error) {
es, ok := emoji[category]
if !ok {
return "", fmt.Errorf("unknown emoji category %q", category)
}
return es[rnd.Intn(len(es))], nil
},
// short and helpers below manipulate valid Go version strings
// for the current needs of the tweet templates.
"short": func(v string) string { return strings.TrimPrefix(v, "go") },
// major extracts the major prefix of a valid Go version.
// For example, major("go1.18.4") == "1.18".
"major": func(v string) (string, error) {
x, ok := version.Go1PointX(v)
if !ok {
return "", fmt.Errorf("internal error: version.Go1PointX(%q) is not ok", v)
}
return fmt.Sprintf("1.%d", x), nil
},
// build extracts the pre-release build number of a valid Go version.
// For example, build("go1.19beta2") == "2".
"build": func(v string) (string, error) {
if i := strings.Index(v, "beta"); i != -1 {
return v[i+len("beta"):], nil
} else if i := strings.Index(v, "rc"); i != -1 {
return v[i+len("rc"):], nil
}
return "", fmt.Errorf("internal error: unhandled pre-release Go version %q", v)
},
}).Parse(tweetTextTmpl)
if err != nil {
return "", err
}
// Select the appropriate template name.
var name string
switch r.Kind {
case KindBeta:
name = "beta"
case KindRC:
name = "rc"
case KindMajor:
name = "major"
case KindMinor:
name = "minor"
default:
return "", fmt.Errorf("unknown release kind: %v", r.Kind)
}
if r.SecondaryVersion != "" && name != "minor" {
return "", fmt.Errorf("tweet template %q doesn't support more than one release; the SecondaryVersion field can only be used in minor releases", name)
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, name, r); err != nil {
return "", err
}
return buf.String(), nil
}
const tweetTextTmpl = `{{define "minor" -}}
{{emoji "release"}} Go {{.Version|short}} {{with .SecondaryVersion}}and {{.|short}} are{{else}}is{{end}} released!
{{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
{{emoji "announce"}} Announcement: {{.Announcement}}
{{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
#golang{{end}}
{{define "beta" -}}
{{emoji "beta-release"}} Go {{.Version|major}} Beta {{.Version|build}} is released!
{{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
{{emoji "try"}} Try it! File bugs! https://go.dev/issue/new
{{emoji "announce"}} Announcement: {{.Announcement}}
{{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
#golang{{end}}
{{define "rc" -}}
{{emoji "rc-release"}} Go {{.Version|major}} Release Candidate {{.Version|build}} is released!
{{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
{{emoji "run"}} Run it in dev! Run it in prod! File bugs! https://go.dev/issue/new
{{emoji "announce"}} Announcement: {{.Announcement}}
{{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
#golang{{end}}
{{define "major" -}}
{{emoji "release"}} Go {{.Version|short}} is released!
{{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
{{emoji "notes"}} Release notes: https://go.dev/doc/go{{.Version|major}}
{{emoji "download"}} Download: https://go.dev/dl/#{{.Version}}
#golang{{end}}`
// emoji is an atlas of emoji for different categories.
//
// The more often an emoji is included in a category,
// the more likely it is to be randomly chosen.
var emoji = map[string][]string{
"release": {
"🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳",
"🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉",
"🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊",
"🌟", "🌟", "🌟", "🌟", "🌟", "🌟", "🌟", "🌟",
"🎆", "🎆", "🎆", "🎆", "🎆", "🎆",
"🆒",
"🕶",
"🤯",
"🧨",
"💃",
"🐕",
"👩🏽‍🔬",
"🌞",
},
"beta-release": {
"🧪", "🧪", "🧪", "🧪", "🧪", "🧪", "🧪", "🧪", "🧪", "🧪",
"⚡️", "⚡️", "⚡️", "⚡️", "⚡️", "⚡️", "⚡️", "⚡️", "⚡️", "⚡️",
"💥",
},
"rc-release": {
"🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳", "🥳",
"🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉", "🎉",
"🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊", "🎊",
"🌞",
},
"security": {
"🔐", "🔐", "🔐", "🔐", "🔐",
"🔏", "🔏",
"🔒",
},
"try": {
"⚙️",
},
"run": {
"🏃‍♀️",
"🏃‍♂️",
"🏖",
},
"announce": {
"🗣", "🗣", "🗣", "🗣", "🗣", "🗣",
"📣", "📣", "📣", "📣", "📣", "📣",
"📢", "📢", "📢", "📢", "📢", "📢",
"🔈", "🔈", "🔈", "🔈", "🔈",
"📡", "📡", "📡", "📡",
"📰",
},
"notes": {
"📝", "📝", "📝", "📝", "📝",
"🗒️", "🗒️", "🗒️", "🗒️", "🗒️",
"📰",
},
"download": {
"⬇️", "⬇️", "⬇️", "⬇️", "⬇️", "⬇️", "⬇️", "⬇️", "⬇️",
"📦", "📦", "📦", "📦", "📦", "📦", "📦", "📦", "📦",
"🗃",
"🚚",
},
}
// tweetImage generates an image to use in the announcement
// tweet for published. It returns the image encoded as PNG,
// and the text displayed in the image.
//
// tweetImage selects a random release archive to highlight.
func tweetImage(published Published, rnd *rand.Rand) (imagePNG []byte, imageText string, _ error) {
a, err := pickRandomArchive(published, rnd)
if err != nil {
return nil, "", err
}
var buf bytes.Buffer
if err := goCmdTmpl.Execute(&buf, map[string]string{
"GoVer": published.Version,
"GOOS": a.OS,
"GOARCH": a.GOARCH(),
"Filename": a.Filename,
"ZeroSize": fmt.Sprintf("%*d", digits(a.Size), 0),
"HalfSize": fmt.Sprintf("%*d", digits(a.Size), a.Size/2),
"FullSize": fmt.Sprint(a.Size),
}); err != nil {
return nil, "", err
}
imageText = buf.String()
m, err := drawTerminal(imageText)
if err != nil {
return nil, "", err
}
// Encode the image in PNG format.
buf.Reset()
err = (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, m)
if err != nil {
return nil, "", err
}
return buf.Bytes(), imageText, nil
}
var goCmdTmpl = template.Must(template.New("").Parse(`$ go install golang.org/dl/{{.GoVer}}@latest
$ {{.GoVer}} download
Downloaded 0.0% ({{.ZeroSize}} / {{.FullSize}} bytes) ...
Downloaded 50.0% ({{.HalfSize}} / {{.FullSize}} bytes) ...
Downloaded 100.0% ({{.FullSize}} / {{.FullSize}} bytes)
Unpacking {{.Filename}} ...
Success. You may now run '{{.GoVer}}'
$ {{.GoVer}} version
go version {{.GoVer}} {{.GOOS}}/{{.GOARCH}}`))
// digits reports the number of digits in the integer i. i must be non-zero.
func digits(i int64) int {
var n int
for ; i != 0; i /= 10 {
n++
}
return n
}
// pickRandomArchive picks one random release archive
// to showcase in an image showing sample output from
// 'go install golang.org/dl/...@latest'.
func pickRandomArchive(published Published, rnd *rand.Rand) (archive WebsiteFile, _ error) {
var archives []WebsiteFile
for _, f := range published.Files {
if f.Kind != "archive" {
// Not an archive type of file, skip. The golang.org/dl commands use archives only.
continue
}
archives = append(archives, f)
}
if len(archives) == 0 {
return WebsiteFile{}, fmt.Errorf("release version %q has 0 archive files", published.Version)
}
return archives[rnd.Intn(len(archives))], nil
}
// drawTerminal draws an image of a terminal window
// with the given text displayed.
func drawTerminal(text string) (image.Image, error) {
// Load font from TTF data.
f, err := opentype.Parse(gomono.TTF)
if err != nil {
return nil, err
}
// Keep image width within 900 px, so that Twitter doesn't convert it to a lossy JPEG.
// See https://twittercommunity.com/t/upcoming-changes-to-png-image-support/118695.
const width, height = 900, 520
m := image.NewNRGBA(image.Rect(0, 0, width, height))
// Background.
draw.Draw(m, m.Bounds(), image.NewUniform(gopherBlue), image.Point{}, draw.Src)
// Shadow.
draw.DrawMask(m, m.Bounds(), image.NewUniform(shadowColor), image.Point{},
roundedRect(image.Rect(50, 80, width-50, height-80).Add(image.Point{Y: 20}), 10), image.Point{}, draw.Over)
// Blur.
m, err = stackblur.Process(m, 80)
if err != nil {
return nil, err
}
// Terminal.
draw.DrawMask(m, m.Bounds(), image.NewUniform(terminalColor), image.Point{},
roundedRect(image.Rect(50, 80, width-50, height-80), 10), image.Point{}, draw.Over)
// Text.
face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: 24, DPI: 72})
if err != nil {
return nil, err
}
d := font.Drawer{Dst: m, Src: image.White, Face: face}
const lineHeight = 32
for n, line := range strings.Split(text, "\n") {
d.Dot = fixed.P(80, 135+n*lineHeight)
d.DrawString(line)
}
return m, nil
}
// roundedRect returns a rounded rectangle with the specified border radius.
func roundedRect(r image.Rectangle, borderRadius int) image.Image {
return roundedRectangle{
r: r,
i: r.Inset(borderRadius),
br: borderRadius,
}
}
type roundedRectangle struct {
r image.Rectangle // Outer bounds.
i image.Rectangle // Inner bounds, border radius away from outer.
br int // Border radius.
}
func (roundedRectangle) ColorModel() color.Model { return color.Alpha16Model }
func (r roundedRectangle) Bounds() image.Rectangle { return r.r }
func (r roundedRectangle) At(x, y int) color.Color {
switch {
case x < r.i.Min.X && y < r.i.Min.Y:
return circle(x-r.i.Min.X, y-r.i.Min.Y, r.br)
case x > r.i.Max.X-1 && y < r.i.Min.Y:
return circle(x-(r.i.Max.X-1), y-r.i.Min.Y, r.br)
case x < r.i.Min.X && y > r.i.Max.Y-1:
return circle(x-r.i.Min.X, y-(r.i.Max.Y-1), r.br)
case x > r.i.Max.X-1 && y > r.i.Max.Y-1:
return circle(x-(r.i.Max.X-1), y-(r.i.Max.Y-1), r.br)
default:
return color.Opaque
}
}
func circle(x, y, r int) color.Alpha16 {
xxyy := float64(x)*float64(x) + float64(y)*float64(y)
if xxyy > float64((r+1)*(r+1)) {
return color.Transparent
} else if xxyy > float64(r*r) {
return color.Alpha16{uint16(0xFFFF * (1 - math.Sqrt(xxyy) - float64(r)))}
}
return color.Opaque
}
var (
// gopherBlue is the Gopher Blue primary color from the Go color palette.
//
// Reference: https://go.dev/s/brandbook.
gopherBlue = color.NRGBA{0, 173, 216, 255} // #00add8.
// terminalColor is the color used as the terminal color.
terminalColor = color.NRGBA{52, 61, 70, 255} // #343d46.
// shadowColor is the color used as the shadow color.
shadowColor = color.NRGBA{0, 0, 0, 140} // #0000008c.
)
type realTwitterClient struct {
twitterAPI *http.Client
}
type realMastodonClient struct {
client *madon.Client
testRecipient string
}
// PostTweet posts a message to a Mastodon account, with specified text, image, and image alt text.
// If the "client" includes a non-empty test recipient, direct the message to that recipient in a
// "direct message", also known as a "private mention".
func (c realMastodonClient) PostTweet(text string, imagePNG []byte, altText string) (tweetURL string, _ error) {
client := c.client
visibility := "public"
// For end-to-end hand testing, send a message to a designated recipient
if c.testRecipient != "" {
text = c.testRecipient + "\n" + text
visibility = "direct"
}
// The documentation says that the name parameter can be empty, but at least one
// Mastodon server disagrees. Make the name match the media format, just in case
// that matters.
att, err := client.UploadMediaReader(bytes.NewReader(imagePNG), "upload.png", altText, "")
if err != nil {
return "upload failure", err
}
postParams := madon.PostStatusParams{
Text: text,
MediaIDs: []string{att.ID},
Visibility: visibility,
Sensitive: false,
SpoilerText: "",
}
status, err := client.PostStatus(postParams)
if err != nil {
return "post failure", err
}
return status.URL, nil
}
// PostTweet implements the TweetTasks.TwitterClient interface.
func (c realTwitterClient) PostTweet(text string, imagePNG []byte, altText string) (tweetURL string, _ error) {
// Make a Twitter API call to upload PNG to upload.twitter.com.
// See https://developer.twitter.com/en/docs/twitter-api/v1/media/upload-media/api-reference/post-media-upload.
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if f, err := w.CreateFormFile("media", "image.png"); err != nil {
return "", err
} else if _, err := f.Write(imagePNG); err != nil {
return "", err
} else if err := w.Close(); err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, "https://upload.twitter.com/1.1/media/upload.json?media_category=tweet_image", &buf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.twitterAPI.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return "", fmt.Errorf("POST /1.1/media/upload.json: non-200 OK status code: %v body: %q", resp.Status, body)
}
var media struct {
ID string `json:"media_id_string"`
}
if err := json.NewDecoder(resp.Body).Decode(&media); err != nil {
return "", err
}
// Make a Twitter API call to post a tweet with the uploaded image.
// See https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets.
var tweetReq struct {
Text string `json:"text"`
Media struct {
MediaIDs []string `json:"media_ids"`
} `json:"media"`
}
tweetReq.Text, tweetReq.Media.MediaIDs = text, []string{media.ID}
buf.Reset()
if err := json.NewEncoder(&buf).Encode(tweetReq); err != nil {
return "", err
}
resp, err = c.twitterAPI.Post("https://api.twitter.com/2/tweets", "application/json", &buf)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if isTweetTooLong(resp, body) {
// A friendlier error for a common error type.
return "", ErrTweetTooLong
}
return "", fmt.Errorf("POST /2/tweets: non-201 Created status code: %v body: %q", resp.Status, body)
}
var tweetResp struct {
Data struct {
ID string
}
}
if err := json.NewDecoder(resp.Body).Decode(&tweetResp); err != nil {
return "", err
}
// Use a generic "username" in the URL since finding it needs another API call.
// As long as the URL has this format, it will redirect to the canonical username.
return "https://twitter.com/username/status/" + tweetResp.Data.ID, nil
}
// ErrTweetTooLong is the error when a tweet is too long.
var ErrTweetTooLong = fmt.Errorf("tweet text length exceeded Twitter's limit")
// isTweetTooLong reports whether the Twitter API response is
// known to represent a "Tweet needs to be a bit shorter." error.
// See https://developer.twitter.com/en/support/twitter-api/error-troubleshooting.
func isTweetTooLong(resp *http.Response, body []byte) bool {
if resp.StatusCode != http.StatusForbidden {
return false
}
var r struct{ Errors []struct{ Code int } }
if err := json.Unmarshal(body, &r); err != nil {
return false
}
return len(r.Errors) == 1 && r.Errors[0].Code == 186
}
// NewTwitterClient creates a Twitter API client authenticated
// to make Twitter API calls using the provided credentials.
func NewTwitterClient(t secret.TwitterCredentials) realTwitterClient {
config := oauth1.NewConfig(t.ConsumerKey, t.ConsumerSecret)
token := oauth1.NewToken(t.AccessTokenKey, t.AccessTokenSecret)
return realTwitterClient{twitterAPI: config.Client(context.Background(), token)}
}
// NewMastodonClient creates a Mastodon API client authenticated
// to make Mastodon API calls using the provided credentials.
// The resulting client may have been permission-limited at its creation
// (e.g., only allowed to upload media and write posts).
// For tests, use NewTestMastodonClient, which creates private messages
// instead.
func NewMastodonClient(config secret.MastodonCredentials) (realMastodonClient, error) {
tok := madon.UserToken{AccessToken: config.AccessToken}
client, err := madon.RestoreApp(config.Application, config.Instance, config.ClientKey, config.ClientSecret, &tok)
if err != nil {
return realMastodonClient{}, err
}
return realMastodonClient{client, ""}, nil
}
// NewTestMastodonClient creates a client that will DM the announcement to the
// designated recipient for end-to-end testing. config.TestRecipient cannot be empty;
// that would result in a public message, which should not happen unintentionally.
func NewTestMastodonClient(config secret.MastodonCredentials, pmTarget string) (realMastodonClient, error) {
if pmTarget == "" {
panic("private message target to NewTestMastodonClient cannot be empty")
}
mc, err := NewMastodonClient(config)
mc.testRecipient = pmTarget
return mc, err
}