blob: 04ce69b2d9f8cfe5ea4063bfb0cf0edb1a07d7fe [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"
"net/url"
"strings"
"text/template"
"time"
"github.com/dghubble/oauth1"
"github.com/esimov/stackblur-go"
"golang.org/x/build/internal/secret"
"golang.org/x/build/internal/workflow"
"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 {
// Version is the Go version that has been released.
//
// The version string must use the same format as Go tags. For example:
// • "go1.17.2" for a minor Go release
// • "go1.18" for a major Go release
// • "go1.18beta1" or "go1.18rc1" for a pre-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.16.10".
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
}
// TweetTasks contains tasks related to the release tweet.
type TweetTasks struct {
// TwitterClient can be used to post a tweet.
TwitterClient 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) (tweetURL string, _ error)
}
// 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
}
// TweetRelease posts a tweet announcing a Go release.
// ErrTweetTooLong is returned if the inputs result in a tweet that's too long.
func (t TweetTasks) TweetRelease(ctx *workflow.TaskContext, versions []string, security string, announcement string) (tweetURL string, _ error) {
if err := oneOrTwoGoVersions(versions); err != nil {
return "", err
}
r := releaseTweet{
Version: versions[0],
Security: security,
Announcement: announcement,
}
if len(versions) == 2 {
r.SecondaryVersion = versions[1]
}
seed := t.RandomSeed
if seed == 0 {
seed = time.Now().UnixNano()
}
rnd := rand.New(rand.NewSource(seed))
// Generate tweet text.
tweetText, err := tweetText(r, rnd)
if err != nil {
return "", err
}
ctx.Printf("tweet text:\n%s\n", tweetText)
// Generate tweet image.
imagePNG, imageText, err := tweetImage(r.Version, rnd)
if err != nil {
return "", err
}
ctx.Printf("tweet image:\n%s\n", imageText)
// Post a tweet via the Twitter API.
if t.TwitterClient == nil {
return "(dry-run)", nil
}
ctx.DisableRetries()
tweetURL, err = t.TwitterClient.PostTweet(tweetText, imagePNG)
return tweetURL, err
}
// tweetText generates the text to use in the announcement
// tweet for release r.
func tweetText(r releaseTweet, 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
},
}).Parse(tweetTextTmpl)
if err != nil {
return "", err
}
// Pick a template name and populate template data
// for this type of release.
var (
name string
data interface{}
)
if i := strings.Index(r.Version, "beta"); i != -1 { // A beta release.
name, data = "beta", struct {
Maj, Beta string
releaseTweet
}{
Maj: r.Version[len("go"):i],
Beta: r.Version[i+len("beta"):],
releaseTweet: r,
}
} else if i := strings.Index(r.Version, "rc"); i != -1 { // Release Candidate.
name, data = "rc", struct {
Maj, RC string
releaseTweet
}{
Maj: r.Version[len("go"):i],
RC: r.Version[i+len("rc"):],
releaseTweet: r,
}
} else if strings.Count(r.Version, ".") == 1 { // Major release like "go1.X".
name, data = "major", struct {
Maj string
releaseTweet
}{
Maj: r.Version[len("go"):],
releaseTweet: r,
}
} else if strings.Count(r.Version, ".") == 2 { // Minor release like "go1.X.Y".
name, data = "minor", struct {
Curr, Prev string
releaseTweet
}{
Curr: r.Version[len("go"):],
Prev: strings.TrimPrefix(r.SecondaryVersion, "go"),
releaseTweet: r,
}
} else {
return "", fmt.Errorf("unknown version format: %q", r.Version)
}
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, data); err != nil {
return "", err
}
return buf.String(), nil
}
const tweetTextTmpl = `{{define "minor" -}}
{{emoji "release"}} Go {{.Curr}} {{with .Prev}}and {{.}} 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 {{.Maj}} Beta {{.Beta}} 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 {{.Maj}} Release Candidate {{.RC}} 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 {{.Maj}} is released!
{{with .Security}}{{emoji "security"}} Security: {{.}}{{"\n\n"}}{{end -}}
{{emoji "notes"}} Release notes: https://go.dev/doc/{{.Version}}
{{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 goVersion. It returns the image encoded as PNG,
// and the text displayed in the image.
//
// tweetImage makes an HTTP GET request to the go.dev/dl/?mode=json
// read-only API to select a random release archive to highlight.
func tweetImage(goVersion string, rnd *rand.Rand) (imagePNG []byte, imageText string, _ error) {
a, err := fetchRandomArchive(goVersion, rnd)
if err != nil {
return nil, "", err
}
var buf bytes.Buffer
if err := goCmdTmpl.Execute(&buf, map[string]string{
"GoVer": goVersion,
"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
}
// fetchRandomArchive downloads all release archives for Go version goVer,
// and selects a random archive to showcase in the image that displays
// sample output from the 'go install golang.org/dl/...@latest' command.
func fetchRandomArchive(goVer string, rnd *rand.Rand) (archive WebsiteFile, _ error) {
archives, err := fetchReleaseArchives(goVer)
if err != nil {
return WebsiteFile{}, err
}
return archives[rnd.Intn(len(archives))], nil
}
func fetchReleaseArchives(goVer string) (archives []WebsiteFile, _ error) {
url := "https://go.dev/dl/?mode=json"
if strings.Contains(goVer, "beta") || strings.Contains(goVer, "rc") ||
goVer == "go1.17" || goVer == "go1.17.1" || goVer == "go1.11.1" /* For TestTweetRelease. */ {
url += "&include=all"
}
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("non-200 OK status code: %v", resp.Status)
} else if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
return nil, fmt.Errorf("got Content-Type %q, want %q", ct, "application/json")
}
var releases []WebsiteRelease
err = json.NewDecoder(resp.Body).Decode(&releases)
if err != nil {
return nil, err
}
for _, r := range releases {
if r.Version != goVer {
continue
}
var archives []WebsiteFile
for _, f := range r.Files {
if f.Kind != "archive" {
continue
}
archives = append(archives, f)
}
if len(archives) == 0 {
return nil, fmt.Errorf("release version %q has 0 archive files", goVer)
}
// Return archives.
return archives, nil
}
return nil, fmt.Errorf("release version %q not found", goVer)
}
// 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
}
// PostTweet implements the TweetTasks.TwitterClient interface.
func (c realTwitterClient) PostTweet(text string, imagePNG []byte) (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 media/upload: 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 update status with uploaded image.
// See https://developer.twitter.com/en/docs/twitter-api/v1/tweets/post-and-engage/api-reference/post-statuses-update.
resp, err = c.twitterAPI.PostForm("https://api.twitter.com/1.1/statuses/update.json", url.Values{
"status": []string{text},
"media_ids": []string{media.ID},
})
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
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 statuses/update: non-200 OK status code: %v body: %q", resp.Status, body)
}
var tweet struct {
ID string `json:"id_str"`
User struct {
ScreenName string `json:"screen_name"`
}
}
if err := json.NewDecoder(resp.Body).Decode(&tweet); err != nil {
return "", err
}
return "https://twitter.com/" + tweet.User.ScreenName + "/status/" + tweet.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)}
}