internal/task: accept secrets explicitly via ExternalConfig struct

Previously, the tweet tasks fetched Twitter API credentials implicitly
from the environment, and a dryRun bool parameter was used to disable
the task from actually posting a tweet (useful for running tests).

This doesn't scale well to being able to supply different credentials,
which is needed to be able to run the task in a staging environment,
using a staging/test set of credentials.

Create an ExternalConfig struct for providing secrets for external
services that tasks need to interact with, making this more explicit.

This will be used by relui in its upcoming "create tweet" workflows.

Update the MailDLCL task analogously to accept the new ExternalConfig
parameter, making it more testable and dry-run-capable in the process.

Also switch to using *workflow.TaskContext (pointer, not value),
since that's what the x/build/internal/workflow package expects.

For golang/go#47402.
Updates golang/go#47405.

Change-Id: I40f0144a57ca0031840fbd1303ab56ac3fefc6c6
Reviewed-on: https://go-review.googlesource.com/c/build/+/382935
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
Trust: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Alex Rakoczy <alex@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/releasebot/README.md b/cmd/releasebot/README.md
index e915dfb..382846c 100644
--- a/cmd/releasebot/README.md
+++ b/cmd/releasebot/README.md
@@ -15,5 +15,7 @@
 * gomote access and a token in your name
 * gcloud application default credentials, and an account with GCS access to golang-org for bucket golang-release-staging
 * **`release-manager` group membership on Gerrit**
+* for `-mode=mail-dl-cl` only, Secret Manager access to Gerrit API secret
+* for `-mode=tweet-*` only, Secret Manager access to Twitter API secret
 
 NOTE: all but the Gerrit permission are ensured by the bot on startup.
diff --git a/cmd/releasebot/gerrit.go b/cmd/releasebot/gerrit.go
new file mode 100644
index 0000000..46a0baf
--- /dev/null
+++ b/cmd/releasebot/gerrit.go
@@ -0,0 +1,32 @@
+// Copyright 2022 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 main
+
+import (
+	"context"
+
+	"golang.org/x/build/buildenv"
+	"golang.org/x/build/gerrit"
+	"golang.org/x/build/internal/secret"
+)
+
+const (
+	// gerritAPIURL is the Gerrit API URL.
+	gerritAPIURL = "https://go-review.googlesource.com"
+)
+
+// loadGerritAuth loads Gerrit API credentials.
+func loadGerritAuth() (gerrit.Auth, error) {
+	sc, err := secret.NewClientInProject(buildenv.Production.ProjectName)
+	if err != nil {
+		return nil, err
+	}
+	defer sc.Close()
+	token, err := sc.Retrieve(context.Background(), secret.NameGobotPassword)
+	if err != nil {
+		return nil, err
+	}
+	return gerrit.BasicAuth("git-gobot.golang.org", token), nil
+}
diff --git a/cmd/releasebot/main.go b/cmd/releasebot/main.go
index ae9ee83..ea4239c 100644
--- a/cmd/releasebot/main.go
+++ b/cmd/releasebot/main.go
@@ -207,20 +207,28 @@
 	}
 	versions := flag.Args()
 
-	fmt.Printf("About to create a golang.org/dl CL for the following Go versions:\n\n\t• %s\n\nOk? (Y/n) ", strings.Join(versions, "\n\t• "))
-	if dryRun {
-		fmt.Println("dry-run")
-		return
+	extCfg := task.ExternalConfig{
+		DryRun: dryRun,
 	}
+	if !dryRun {
+		extCfg.GerritAPI.URL = gerritAPIURL
+		var err error
+		extCfg.GerritAPI.Auth, err = loadGerritAuth()
+		if err != nil {
+			log.Fatalln("error loading Gerrit API credentials:", err)
+		}
+	}
+
+	fmt.Printf("About to create a golang.org/dl CL for the following Go versions:\n\n\t• %s\n\nOk? (Y/n) ", strings.Join(versions, "\n\t• "))
 	var resp string
 	if _, err := fmt.Scanln(&resp); err != nil {
 		log.Fatalln(err)
 	} else if resp != "Y" && resp != "y" {
 		log.Fatalln("stopped as requested")
 	}
-	changeURL, err := task.MailDLCL(context.Background(), versions)
+	changeURL, err := task.MailDLCL(&workflow.TaskContext{Context: context.Background(), Logger: log.Default()}, versions, extCfg)
 	if err != nil {
-		log.Fatalf(`task.MailDLCL(ctx, %#v) failed:
+		log.Fatalf(`task.MailDLCL(ctx, %#v, extCfg) failed:
 
 	%v
 
@@ -228,7 +236,7 @@
 consider the following steps:
 
 	git clone https://go.googlesource.com/dl && cd dl
-	go run ./internal/genv goX.Y.Z goX.A.B
+	# create files displayed in the log above
 	git add .
 	git commit -m "dl: add goX.Y.Z and goX.A.B"
 	git codereview mail -trybot -trust
@@ -252,6 +260,17 @@
 		log.Fatalln("error parsing release tweet JSON object:", err)
 	}
 
+	extCfg := task.ExternalConfig{
+		DryRun: dryRun,
+	}
+	if !dryRun {
+		var err error
+		extCfg.TwitterAPI, err = loadTwitterAuth()
+		if err != nil {
+			log.Fatalln("error loading Twitter API credentials:", err)
+		}
+	}
+
 	versions := []string{tweet.Version}
 	if tweet.SecondaryVersion != "" {
 		versions = append(versions, tweet.SecondaryVersion+" (secondary)")
@@ -272,20 +291,20 @@
 	} else if resp != "Y" && resp != "y" {
 		log.Fatalln("stopped as requested")
 	}
-	tweetRelease := map[string]func(workflow.TaskContext, task.ReleaseTweet, bool) (string, error){
+	tweetRelease := map[string]func(*workflow.TaskContext, task.ReleaseTweet, task.ExternalConfig) (string, error){
 		"minor": task.TweetMinorRelease,
 		"beta":  task.TweetBetaRelease,
 		"rc":    task.TweetRCRelease,
 		"major": task.TweetMajorRelease,
 	}[kind]
-	tweetURL, err := tweetRelease(workflow.TaskContext{Context: context.Background(), Logger: log.Default()}, tweet, dryRun)
+	tweetURL, err := tweetRelease(&workflow.TaskContext{Context: context.Background(), Logger: log.Default()}, tweet, extCfg)
 	if errors.Is(err, task.ErrTweetTooLong) && len([]rune(tweet.Security)) > 120 {
 		log.Fatalf(`A tweet was not created because it's too long.
 
 The provided security sentence is somewhat long (%d characters),
 so try making it shorter to avoid exceeding Twitter's limits.`, len([]rune(tweet.Security)))
 	} else if err != nil {
-		log.Fatalf(`tweetRelease(ctx, %#v) failed:
+		log.Fatalf(`tweetRelease(ctx, %#v, extCfg) failed:
 
 	%v
 
diff --git a/cmd/releasebot/twitter.go b/cmd/releasebot/twitter.go
new file mode 100644
index 0000000..27aba70
--- /dev/null
+++ b/cmd/releasebot/twitter.go
@@ -0,0 +1,32 @@
+// Copyright 2022 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 main
+
+import (
+	"context"
+	"encoding/json"
+
+	"golang.org/x/build/buildenv"
+	"golang.org/x/build/internal/secret"
+)
+
+// loadTwitterAuth loads Twitter API credentials.
+func loadTwitterAuth() (secret.TwitterCredentials, error) {
+	sc, err := secret.NewClientInProject(buildenv.Production.ProjectName)
+	if err != nil {
+		return secret.TwitterCredentials{}, err
+	}
+	defer sc.Close()
+	secretJSON, err := sc.Retrieve(context.Background(), secret.NameTwitterAPISecret)
+	if err != nil {
+		return secret.TwitterCredentials{}, err
+	}
+	var v secret.TwitterCredentials
+	err = json.Unmarshal([]byte(secretJSON), &v)
+	if err != nil {
+		return secret.TwitterCredentials{}, err
+	}
+	return v, nil
+}
diff --git a/internal/secret/gcp_secret_manager.go b/internal/secret/gcp_secret_manager.go
index 8cf43b4..ca8ea54 100644
--- a/internal/secret/gcp_secret_manager.go
+++ b/internal/secret/gcp_secret_manager.go
@@ -8,6 +8,7 @@
 
 import (
 	"context"
+	"fmt"
 	"io"
 	"log"
 	"path"
@@ -34,7 +35,7 @@
 	// NameGitHubSSHKey is the secret name for the GitHub SSH private key.
 	NameGitHubSSHKey = "github-ssh-private-key"
 
-	// NameGobotPassword is the secret name for the Gobot password.
+	// NameGobotPassword is the secret name for the gobot@golang.org Gerrit account password.
 	NameGobotPassword = "gobot-password"
 
 	// NameGomoteSSHPrivateKey is the secret name for the gomote SSH private key.
@@ -66,7 +67,7 @@
 	// posting tweets from the Go project's Twitter account (twitter.com/golang).
 	//
 	// The secret value encodes relevant keys and their secrets as
-	// a JSON object:
+	// a JSON object that can be unmarshaled into TwitterCredentials:
 	//
 	// 	{
 	// 		"ConsumerKey":       "...",
@@ -75,8 +76,31 @@
 	// 		"AccessTokenSecret": "..."
 	// 	}
 	NameTwitterAPISecret = "twitter-api-secret"
+	// NameStagingTwitterAPISecret is the secret name for Twitter API credentials
+	// for posting tweets using a staging test Twitter account.
+	//
+	// This secret is available in the Secret Manager of the x/build staging GCP project.
+	//
+	// The secret value encodes relevant keys and their secrets as
+	// a JSON object that can be unmarshaled into TwitterCredentials.
+	NameStagingTwitterAPISecret = "staging-" + NameTwitterAPISecret
 )
 
+// TwitterCredentials holds Twitter API credentials.
+type TwitterCredentials struct {
+	ConsumerKey       string
+	ConsumerSecret    string
+	AccessTokenKey    string
+	AccessTokenSecret string
+}
+
+func (t TwitterCredentials) String() string {
+	return fmt.Sprintf("{%s (redacted) %s (redacted)}", t.ConsumerKey, t.AccessTokenKey)
+}
+func (t TwitterCredentials) GoString() string {
+	return fmt.Sprintf("secret.TwitterCredentials{ConsumerKey:%q ConsumerSecret:(redacted) AccessTokenKey:%q AccessTokenSecret:(redacted)}", t.ConsumerKey, t.AccessTokenKey)
+}
+
 type secretClient interface {
 	AccessSecretVersion(ctx context.Context, req *secretmanagerpb.AccessSecretVersionRequest, opts ...gax.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error)
 	io.Closer
diff --git a/internal/task/dlcl.go b/internal/task/dlcl.go
index a4d8c50..29877ab 100644
--- a/internal/task/dlcl.go
+++ b/internal/task/dlcl.go
@@ -6,7 +6,6 @@
 
 import (
 	"bytes"
-	"context"
 	"fmt"
 	"go/format"
 	"path"
@@ -15,9 +14,8 @@
 	"text/template"
 	"time"
 
-	"golang.org/x/build/buildenv"
 	"golang.org/x/build/gerrit"
-	"golang.org/x/build/internal/secret"
+	"golang.org/x/build/internal/workflow"
 )
 
 // MailDLCL mails a golang.org/dl CL that adds commands for the
@@ -28,9 +26,8 @@
 // 	• "go1.18" for a major Go release
 // 	• "go1.18beta1" or "go1.18rc1" for a pre-release
 //
-// Credentials are fetched from Secret Manager.
 // On success, the URL of the change is returned, like "https://go.dev/cl/123".
-func MailDLCL(ctx context.Context, versions []string) (changeURL string, _ error) {
+func MailDLCL(ctx *workflow.TaskContext, versions []string, e ExternalConfig) (changeURL string, _ error) {
 	if len(versions) < 1 || len(versions) > 2 {
 		return "", fmt.Errorf("got %d Go versions, want 1 or 2", len(versions))
 	}
@@ -67,14 +64,17 @@
 			return "", fmt.Errorf("could not gofmt: %v", err)
 		}
 		files[path.Join(ver, "main.go")] = string(gofmted)
+		if log := ctx.Logger; log != nil {
+			log.Printf("file %q (command %q):\n%s", path.Join(ver, "main.go"), "golang.org/dl/"+ver, gofmted)
+		}
 	}
 
 	// Create a Gerrit CL using the Gerrit API.
-	gobot, err := gobot()
-	if err != nil {
-		return "", err
+	if e.DryRun {
+		return "(dry-run)", nil
 	}
-	ci, err := gobot.CreateChange(ctx, gerrit.ChangeInput{
+	cl := gerrit.NewClient(e.GerritAPI.URL, e.GerritAPI.Auth)
+	c, err := cl.CreateChange(ctx, gerrit.ChangeInput{
 		Project: "dl",
 		Subject: "dl: add " + strings.Join(versions, " and "),
 		Branch:  "master",
@@ -82,18 +82,18 @@
 	if err != nil {
 		return "", err
 	}
-	changeID := fmt.Sprintf("%s~%d", ci.Project, ci.ChangeNumber)
+	changeID := fmt.Sprintf("%s~%d", c.Project, c.ChangeNumber)
 	for path, content := range files {
-		err := gobot.ChangeFileContentInChangeEdit(ctx, changeID, path, content)
+		err := cl.ChangeFileContentInChangeEdit(ctx, changeID, path, content)
 		if err != nil {
 			return "", err
 		}
 	}
-	err = gobot.PublishChangeEdit(ctx, changeID)
+	err = cl.PublishChangeEdit(ctx, changeID)
 	if err != nil {
 		return "", err
 	}
-	return fmt.Sprintf("https://go.dev/cl/%d", ci.ChangeNumber), nil
+	return fmt.Sprintf("https://go.dev/cl/%d", c.ChangeNumber), nil
 }
 
 func verifyGoVersions(versions ...string) error {
@@ -153,18 +153,3 @@
 	version.Run("{{.Version}}")
 }
 `))
-
-// gobot creates an authenticated Gerrit API client
-// that uses the gobot@golang.org Gerrit account.
-func gobot() (*gerrit.Client, error) {
-	sc, err := secret.NewClientInProject(buildenv.Production.ProjectName)
-	if err != nil {
-		return nil, err
-	}
-	defer sc.Close()
-	token, err := sc.Retrieve(context.Background(), secret.NameGobotPassword)
-	if err != nil {
-		return nil, err
-	}
-	return gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth("git-gobot.golang.org", token)), nil
-}
diff --git a/internal/task/dlcl_test.go b/internal/task/dlcl_test.go
new file mode 100644
index 0000000..0b77e71
--- /dev/null
+++ b/internal/task/dlcl_test.go
@@ -0,0 +1,184 @@
+// Copyright 2022 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"
+	"fmt"
+	"testing"
+	"time"
+
+	"golang.org/x/build/internal/workflow"
+)
+
+func TestMailDLCL(t *testing.T) {
+	year := fmt.Sprint(time.Now().UTC().Year())
+	tests := [...]struct {
+		name    string
+		in      []string
+		wantLog string
+	}{
+		{
+			name: "minor",
+			in:   []string{"go1.17.1", "go1.16.8"},
+			wantLog: `file "go1.17.1/main.go" (command "golang.org/dl/go1.17.1"):
+// Copyright ` + year + ` 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.
+
+// The go1.17.1 command runs the go command from Go 1.17.1.
+//
+// To install, run:
+//
+//     $ go install golang.org/dl/go1.17.1@latest
+//     $ go1.17.1 download
+//
+// And then use the go1.17.1 command as if it were your normal go
+// command.
+//
+// See the release notes at https://go.dev/doc/devel/release#go1.17.minor.
+//
+// File bugs at https://go.dev/issue/new.
+package main
+
+import "golang.org/dl/internal/version"
+
+func main() {
+	version.Run("go1.17.1")
+}
+file "go1.16.8/main.go" (command "golang.org/dl/go1.16.8"):
+// Copyright ` + year + ` 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.
+
+// The go1.16.8 command runs the go command from Go 1.16.8.
+//
+// To install, run:
+//
+//     $ go install golang.org/dl/go1.16.8@latest
+//     $ go1.16.8 download
+//
+// And then use the go1.16.8 command as if it were your normal go
+// command.
+//
+// See the release notes at https://go.dev/doc/devel/release#go1.16.minor.
+//
+// File bugs at https://go.dev/issue/new.
+package main
+
+import "golang.org/dl/internal/version"
+
+func main() {
+	version.Run("go1.16.8")
+}` + "\n",
+		},
+		{
+			name: "beta",
+			in:   []string{"go1.17beta1"},
+			wantLog: `file "go1.17beta1/main.go" (command "golang.org/dl/go1.17beta1"):
+// Copyright ` + year + ` 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.
+
+// The go1.17beta1 command runs the go command from Go 1.17beta1.
+//
+// To install, run:
+//
+//     $ go install golang.org/dl/go1.17beta1@latest
+//     $ go1.17beta1 download
+//
+// And then use the go1.17beta1 command as if it were your normal go
+// command.
+//
+// See the release notes at https://tip.golang.org/doc/go1.17.
+//
+// File bugs at https://go.dev/issue/new.
+package main
+
+import "golang.org/dl/internal/version"
+
+func main() {
+	version.Run("go1.17beta1")
+}` + "\n",
+		},
+		{
+			name: "rc",
+			in:   []string{"go1.17rc2"},
+			wantLog: `file "go1.17rc2/main.go" (command "golang.org/dl/go1.17rc2"):
+// Copyright ` + year + ` 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.
+
+// The go1.17rc2 command runs the go command from Go 1.17rc2.
+//
+// To install, run:
+//
+//     $ go install golang.org/dl/go1.17rc2@latest
+//     $ go1.17rc2 download
+//
+// And then use the go1.17rc2 command as if it were your normal go
+// command.
+//
+// See the release notes at https://tip.golang.org/doc/go1.17.
+//
+// File bugs at https://go.dev/issue/new.
+package main
+
+import "golang.org/dl/internal/version"
+
+func main() {
+	version.Run("go1.17rc2")
+}` + "\n",
+		},
+		{
+			name: "major",
+			in:   []string{"go1.17"},
+			wantLog: `file "go1.17/main.go" (command "golang.org/dl/go1.17"):
+// Copyright ` + year + ` 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.
+
+// The go1.17 command runs the go command from Go 1.17.
+//
+// To install, run:
+//
+//     $ go install golang.org/dl/go1.17@latest
+//     $ go1.17 download
+//
+// And then use the go1.17 command as if it were your normal go
+// command.
+//
+// See the release notes at https://go.dev/doc/go1.17.
+//
+// File bugs at https://go.dev/issue/new.
+package main
+
+import "golang.org/dl/internal/version"
+
+func main() {
+	version.Run("go1.17")
+}` + "\n",
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			// Call the mail a dl CL task function in dry-run mode so it
+			// doesn't actually try to mail a dl CL, but capture its log.
+			var buf bytes.Buffer
+			ctx := &workflow.TaskContext{Context: context.Background(), Logger: fmtWriter{&buf}}
+			changeURL, err := MailDLCL(ctx, tc.in, ExternalConfig{DryRun: true})
+			if err != nil {
+				t.Fatal("got a non-nil error:", err)
+			}
+			if got, want := changeURL, "(dry-run)"; got != want {
+				t.Errorf("unexpected changeURL: got = %q, want %q", got, want)
+			}
+			if got, want := buf.String(), tc.wantLog; got != want {
+				t.Errorf("unexpected log:\ngot:\n%s\nwant:\n%s", got, want)
+			}
+		})
+	}
+}
diff --git a/internal/task/doc.go b/internal/task/doc.go
deleted file mode 100644
index 687c05a..0000000
--- a/internal/task/doc.go
+++ /dev/null
@@ -1,6 +0,0 @@
-// 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 implements tasks involved in making a Go release.
-package task
diff --git a/internal/task/task.go b/internal/task/task.go
new file mode 100644
index 0000000..5e0b7c2
--- /dev/null
+++ b/internal/task/task.go
@@ -0,0 +1,32 @@
+// 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 implements tasks involved in making a Go release.
+package task
+
+import (
+	"golang.org/x/build/gerrit"
+	"golang.org/x/build/internal/secret"
+)
+
+// ExternalConfig holds configuration and credentials for external
+// services that tasks need to interact with as part of their work.
+type ExternalConfig struct {
+	// DryRun is whether the dry-run mode is on.
+	//
+	// In dry-run mode, tasks are expected to report
+	// what would be done, without changing anything.
+	DryRun bool
+
+	// GerritAPI specifies a Gerrit API server where
+	// Go project CLs can be mailed.
+	GerritAPI struct {
+		URL  string // Gerrit API URL. For example, "https://go-review.googlesource.com".
+		Auth gerrit.Auth
+	}
+
+	// TwitterAPI holds Twitter API credentials that
+	// can be used to post a tweet.
+	TwitterAPI secret.TwitterCredentials
+}
diff --git a/internal/task/tweet.go b/internal/task/tweet.go
index 8f04d50..f1e4025 100644
--- a/internal/task/tweet.go
+++ b/internal/task/tweet.go
@@ -26,7 +26,6 @@
 	"github.com/dghubble/oauth1"
 	"github.com/esimov/stackblur-go"
 	"github.com/golang/freetype/truetype"
-	"golang.org/x/build/buildenv"
 	"golang.org/x/build/internal/secret"
 	"golang.org/x/build/internal/workflow"
 	"golang.org/x/image/font"
@@ -80,7 +79,7 @@
 
 // TweetMinorRelease posts a tweet announcing a minor Go release.
 // ErrTweetTooLong is returned if the inputs result in a tweet that's too long.
-func TweetMinorRelease(ctx workflow.TaskContext, r ReleaseTweet, dryRun bool) (tweetURL string, _ error) {
+func TweetMinorRelease(ctx *workflow.TaskContext, r ReleaseTweet, e ExternalConfig) (tweetURL string, _ error) {
 	if err := verifyGoVersions(r.Version, r.SecondaryVersion); err != nil {
 		return "", err
 	}
@@ -88,12 +87,12 @@
 		return "", fmt.Errorf("announcement URL %q doesn't have the expected prefix %q", r.Announcement, announcementPrefix)
 	}
 
-	return tweetRelease(ctx, r, dryRun)
+	return tweetRelease(ctx, r, e)
 }
 
 // TweetBetaRelease posts a tweet announcing a beta Go release.
 // ErrTweetTooLong is returned if the inputs result in a tweet that's too long.
-func TweetBetaRelease(ctx workflow.TaskContext, r ReleaseTweet, dryRun bool) (tweetURL string, _ error) {
+func TweetBetaRelease(ctx *workflow.TaskContext, r ReleaseTweet, e ExternalConfig) (tweetURL string, _ error) {
 	if r.SecondaryVersion != "" {
 		return "", fmt.Errorf("got 2 Go versions, want 1")
 	}
@@ -104,12 +103,12 @@
 		return "", fmt.Errorf("announcement URL %q doesn't have the expected prefix %q", r.Announcement, announcementPrefix)
 	}
 
-	return tweetRelease(ctx, r, dryRun)
+	return tweetRelease(ctx, r, e)
 }
 
 // TweetRCRelease posts a tweet announcing a Go release candidate.
 // ErrTweetTooLong is returned if the inputs result in a tweet that's too long.
-func TweetRCRelease(ctx workflow.TaskContext, r ReleaseTweet, dryRun bool) (tweetURL string, _ error) {
+func TweetRCRelease(ctx *workflow.TaskContext, r ReleaseTweet, e ExternalConfig) (tweetURL string, _ error) {
 	if r.SecondaryVersion != "" {
 		return "", fmt.Errorf("got 2 Go versions, want 1")
 	}
@@ -120,14 +119,14 @@
 		return "", fmt.Errorf("announcement URL %q doesn't have the expected prefix %q", r.Announcement, announcementPrefix)
 	}
 
-	return tweetRelease(ctx, r, dryRun)
+	return tweetRelease(ctx, r, e)
 }
 
 const announcementPrefix = "https://groups.google.com/g/golang-announce/c/"
 
 // TweetMajorRelease posts a tweet announcing a major Go release.
 // ErrTweetTooLong is returned if the inputs result in a tweet that's too long.
-func TweetMajorRelease(ctx workflow.TaskContext, r ReleaseTweet, dryRun bool) (tweetURL string, _ error) {
+func TweetMajorRelease(ctx *workflow.TaskContext, r ReleaseTweet, e ExternalConfig) (tweetURL string, _ error) {
 	if r.SecondaryVersion != "" {
 		return "", fmt.Errorf("got 2 Go versions, want 1")
 	}
@@ -138,11 +137,11 @@
 		return "", fmt.Errorf("major release tweet doesn't accept an accouncement URL")
 	}
 
-	return tweetRelease(ctx, r, dryRun)
+	return tweetRelease(ctx, r, e)
 }
 
 // tweetRelease posts a tweet announcing a Go release.
-func tweetRelease(ctx workflow.TaskContext, r ReleaseTweet, dryRun bool) (tweetURL string, _ error) {
+func tweetRelease(ctx *workflow.TaskContext, r ReleaseTweet, e ExternalConfig) (tweetURL string, _ error) {
 	rnd := rand.New(rand.NewSource(r.seed()))
 
 	// Generate tweet text.
@@ -163,11 +162,11 @@
 		log.Printf("tweet image:\n%s\n", imageText)
 	}
 
-	// Post a tweet using the twitter.com/golang account.
-	if dryRun {
+	// Post a tweet via the Twitter API.
+	if e.DryRun {
 		return "(dry-run)", nil
 	}
-	cl, err := twitterClient()
+	cl, err := twitterClient(e.TwitterAPI)
 	if err != nil {
 		return "", err
 	}
@@ -662,28 +661,10 @@
 	return len(r.Errors) == 1 && r.Errors[0].Code == 186
 }
 
-// twitterClient creates an HTTP client authenticated to make
-// Twitter API calls on behalf of the twitter.com/golang account.
-func twitterClient() (*http.Client, error) {
-	sc, err := secret.NewClientInProject(buildenv.Production.ProjectName)
-	if err != nil {
-		return nil, err
-	}
-	defer sc.Close()
-	secretJSON, err := sc.Retrieve(context.Background(), secret.NameTwitterAPISecret)
-	if err != nil {
-		return nil, err
-	}
-	var s struct {
-		ConsumerKey       string
-		ConsumerSecret    string
-		AccessTokenKey    string
-		AccessTokenSecret string
-	}
-	if err := json.Unmarshal([]byte(secretJSON), &s); err != nil {
-		return nil, err
-	}
-	config := oauth1.NewConfig(s.ConsumerKey, s.ConsumerSecret)
-	token := oauth1.NewToken(s.AccessTokenKey, s.AccessTokenSecret)
+// twitterClient creates an HTTP client authenticated to
+// make Twitter API calls using the provided credentials.
+func twitterClient(t secret.TwitterCredentials) (*http.Client, error) {
+	config := oauth1.NewConfig(t.ConsumerKey, t.ConsumerSecret)
+	token := oauth1.NewToken(t.AccessTokenKey, t.AccessTokenSecret)
 	return config.Client(context.Background(), token), nil
 }
diff --git a/internal/task/tweet_test.go b/internal/task/tweet_test.go
index 68bbdf1..f134981 100644
--- a/internal/task/tweet_test.go
+++ b/internal/task/tweet_test.go
@@ -26,7 +26,7 @@
 
 	tests := [...]struct {
 		name    string
-		taskFn  func(workflow.TaskContext, ReleaseTweet, bool) (string, error)
+		taskFn  func(*workflow.TaskContext, ReleaseTweet, ExternalConfig) (string, error)
 		in      ReleaseTweet
 		wantLog string
 	}{
@@ -154,8 +154,8 @@
 			// Call the tweet task function in dry-run mode so it
 			// doesn't actually try to tweet, but capture its log.
 			var buf bytes.Buffer
-			ctx := workflow.TaskContext{Context: context.Background(), Logger: fmtWriter{&buf}}
-			tweetURL, err := tc.taskFn(ctx, tc.in, true)
+			ctx := &workflow.TaskContext{Context: context.Background(), Logger: fmtWriter{&buf}}
+			tweetURL, err := tc.taskFn(ctx, tc.in, ExternalConfig{DryRun: true})
 			if err != nil {
 				t.Fatal("got a non-nil error:", err)
 			}