internal/task: integration test for x repo tagging

Add an integration test for the x repo tagging workflow. Most of the
work is test infrastructure. Move the fake buildlet types into the task
package, and create a new fake Gerrit implementation that tracks tags
and commits. Also use it in the older release integration tests.

The coverage of the x repo tagging process itself is a bit thin but this
is a good starting point.

For golang/go#48523.

Change-Id: Iabf9391b56f6becadd401b8ae37a9e4b8cc5d24f
Reviewed-on: https://go-review.googlesource.com/c/build/+/425495
Auto-Submit: Heschi Kreinick <heschi@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Run-TryBot: Heschi Kreinick <heschi@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
diff --git a/internal/relui/buildrelease_test.go b/internal/relui/buildrelease_test.go
index 323b221..e096e58 100644
--- a/internal/relui/buildrelease_test.go
+++ b/internal/relui/buildrelease_test.go
@@ -19,7 +19,6 @@
 	"net/http/httptest"
 	"net/url"
 	"os"
-	"os/exec"
 	"path/filepath"
 	"regexp"
 	"runtime"
@@ -32,12 +31,10 @@
 	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/google/go-github/github"
 	"github.com/google/uuid"
-	"golang.org/x/build/buildlet"
 	"golang.org/x/build/gerrit"
 	"golang.org/x/build/internal"
 	"golang.org/x/build/internal/gcsfs"
 	"golang.org/x/build/internal/task"
-	"golang.org/x/build/internal/untar"
 	"golang.org/x/build/internal/workflow"
 )
 
@@ -61,8 +58,9 @@
 
 type releaseTestDeps struct {
 	ctx            context.Context
-	buildlets      *fakeBuildlets
-	gerrit         *fakeGerrit
+	buildlets      *task.FakeBuildlets
+	goRepo         *task.FakeRepo
+	gerrit         *reviewerCheckGerrit
 	versionTasks   *task.VersionTasks
 	buildTasks     *BuildReleaseTasks
 	milestoneTasks *task.MilestoneTasks
@@ -81,12 +79,7 @@
 	// Set up a server that will be used to serve inputs to the build.
 	bootstrapServer := httptest.NewServer(http.HandlerFunc(serveBootstrap))
 	t.Cleanup(bootstrapServer.Close)
-	fakeBuildlets := &fakeBuildlets{
-		t:       t,
-		dir:     t.TempDir(),
-		httpURL: bootstrapServer.URL,
-		logs:    map[string][]*[]string{},
-	}
+	fakeBuildlets := task.NewFakeBuildlets(t, bootstrapServer.URL)
 
 	// Set up the fake signing process.
 	scratchDir := t.TempDir()
@@ -123,7 +116,13 @@
 		return nil
 	}
 
-	gerrit := &fakeGerrit{createdTags: map[string]string{}}
+	goRepo := task.NewFakeRepo(t, "go")
+	base := goRepo.Commit(goFiles)
+	goRepo.Tag("go1.17", base)
+	dlRepo := task.NewFakeRepo(t, "dl")
+	fakeGerrit := task.NewFakeGerrit(t, goRepo, dlRepo)
+
+	gerrit := &reviewerCheckGerrit{FakeGerrit: fakeGerrit}
 	versionTasks := &task.VersionTasks{
 		Gerrit:    gerrit,
 		GoProject: "go",
@@ -137,16 +136,14 @@
 		},
 	}
 
-	snapshotServer := httptest.NewServer(http.HandlerFunc(serveSnapshot))
-	t.Cleanup(snapshotServer.Close)
 	buildTasks := &BuildReleaseTasks{
 		GerritClient:     gerrit,
 		GerritHTTPClient: http.DefaultClient,
-		GerritURL:        snapshotServer.URL,
+		GerritURL:        fakeGerrit.GerritURL() + "/go",
 		GCSClient:        nil,
 		ScratchURL:       "file://" + filepath.ToSlash(scratchDir),
 		ServingURL:       "file://" + filepath.ToSlash(servingDir),
-		CreateBuildlet:   fakeBuildlets.createBuildlet,
+		CreateBuildlet:   fakeBuildlets.CreateBuildlet,
 		DownloadURL:      dlServer.URL,
 		PublishFile:      publishFile,
 		ApproveAction: func(ctx *workflow.TaskContext) error {
@@ -162,6 +159,7 @@
 	return &releaseTestDeps{
 		ctx:            ctx,
 		buildlets:      fakeBuildlets,
+		goRepo:         goRepo,
 		gerrit:         gerrit,
 		versionTasks:   versionTasks,
 		buildTasks:     buildTasks,
@@ -241,25 +239,30 @@
 		Kind: "installer",
 	}, "I'm a .pkg!\n")
 
-	wantCLs := 2 // VERSION bump, DL
-	if kind == task.KindBeta {
-		wantCLs--
+	head, err := deps.gerrit.ReadBranchHead(deps.ctx, "dl", "master")
+	if err != nil {
+		t.Fatal(err)
 	}
-	if deps.gerrit.changesCreated != wantCLs {
-		t.Errorf("workflow sent %v changes to Gerrit, want %v", deps.gerrit.changesCreated, wantCLs)
+	content, err := deps.gerrit.ReadFile(deps.ctx, "dl", head, wantVersion+"/main.go")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(string(content), fmt.Sprintf("version.Run(%q)", wantVersion)) {
+		t.Errorf("unexpected dl content: %v", content)
 	}
 
-	if len(deps.gerrit.createdTags) != 1 {
-		t.Errorf("workflow created %v tags, want 1", deps.gerrit.createdTags)
+	tag, err := deps.gerrit.GetTag(deps.ctx, "go", wantVersion)
+	if err != nil {
+		t.Fatal(err)
 	}
 
-	// TODO: consider logging this to golden files?
-	for name, logs := range deps.buildlets.logs {
-		t.Logf("%v buildlets:", name)
-		for _, group := range logs {
-			for _, line := range *group {
-				t.Log(line)
-			}
+	if kind != task.KindBeta {
+		version, err := deps.gerrit.ReadFile(deps.ctx, "go", tag.Revision, "VERSION")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if string(version) != wantVersion {
+			t.Errorf("VERSION file is %q, expected %q", version, wantVersion)
 		}
 	}
 }
@@ -267,26 +270,20 @@
 func testSecurity(t *testing.T, mergeFixes bool) {
 	deps := newReleaseTestDeps(t, "go1.18rc1")
 
-	// Set up the fake merge process. Once we stop to ask for approval, switch
-	// the public Gerrit server to serve the same content as the private server.
-	approved := false
-	privateServer := httptest.NewServer(http.HandlerFunc(serveSecureSnapshot))
-	t.Cleanup(privateServer.Close)
-	deps.buildTasks.PrivateGerritURL = privateServer.URL
-
-	publicServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if approved && mergeFixes {
-			serveSecureSnapshot(w, r)
-		} else {
-			serveSnapshot(w, r)
-		}
-	}))
-	t.Cleanup(publicServer.Close)
-	deps.buildTasks.GerritURL = publicServer.URL
+	// Set up the fake merge process. Once we stop to ask for approval, commit
+	// the fix to the public server.
+	privateRepo := task.NewFakeRepo(t, "go-private")
+	privateRepo.Commit(goFiles)
+	securityFix := map[string]string{"security.txt": "This file makes us secure"}
+	privateRef := privateRepo.Commit(securityFix)
+	privateGerrit := task.NewFakeGerrit(t, privateRepo)
+	deps.buildTasks.PrivateGerritURL = privateGerrit.GerritURL() + "/go-private"
 
 	defaultApprove := deps.buildTasks.ApproveAction
 	deps.buildTasks.ApproveAction = func(tc *workflow.TaskContext) error {
-		approved = true
+		if mergeFixes {
+			deps.goRepo.Commit(securityFix)
+		}
 		return defaultApprove(tc)
 	}
 
@@ -297,7 +294,7 @@
 
 	w, err := workflow.Start(wd, map[string]interface{}{
 		"Targets to skip testing (or 'all') (optional)":            []string{"js-wasm"},
-		"Ref from the private repository to build from (optional)": "security-ref",
+		"Ref from the private repository to build from (optional)": privateRef,
 	})
 	if err != nil {
 		t.Fatal(err)
@@ -403,68 +400,21 @@
 exit 0
 `
 
+var goFiles = map[string]string{
+	"src/make.bash": makeScript,
+	"src/make.bat":  makeScript,
+	"src/all.bash":  allScript,
+	"src/all.bat":   allScript,
+	"src/race.bash": allScript,
+	"src/race.bat":  allScript,
+}
+
 func serveBootstrap(w http.ResponseWriter, r *http.Request) {
-	serveTarball("go-builder-data/go", map[string]string{
+	task.ServeTarball("go-builder-data/go", map[string]string{
 		"bin/go": "I'm a dummy bootstrap go command!",
 	}, w, r)
 }
 
-func serveSnapshot(w http.ResponseWriter, r *http.Request) {
-	serveTarball("+archive", map[string]string{
-		"src/make.bash": makeScript,
-		"src/make.bat":  makeScript,
-		"src/all.bash":  allScript,
-		"src/all.bat":   allScript,
-		"src/race.bash": allScript,
-		"src/race.bat":  allScript,
-	}, w, r)
-}
-
-func serveSecureSnapshot(w http.ResponseWriter, r *http.Request) {
-	serveTarball("+archive", map[string]string{
-		"src/make.bash": makeScript,
-		"src/make.bat":  makeScript,
-		"src/all.bash":  allScript,
-		"src/all.bat":   allScript,
-		"src/race.bash": allScript,
-		"src/race.bat":  allScript,
-		"security.txt":  "This file makes us secure",
-	}, w, r)
-}
-
-// serveTarballs serves the files the release process relies on.
-// PutTarFromURL is hardcoded to read from this server.
-func serveTarball(pathMatch string, files map[string]string, w http.ResponseWriter, r *http.Request) {
-	if !strings.Contains(r.URL.Path, pathMatch) {
-		w.WriteHeader(http.StatusNotFound)
-		return
-	}
-
-	gzw := gzip.NewWriter(w)
-	tw := tar.NewWriter(gzw)
-
-	for name, contents := range files {
-		if err := tw.WriteHeader(&tar.Header{
-			Typeflag: tar.TypeReg,
-			Name:     name,
-			Size:     int64(len(contents)),
-			Mode:     0777,
-		}); err != nil {
-			panic(err)
-		}
-		if _, err := tw.Write([]byte(contents)); err != nil {
-			panic(err)
-		}
-	}
-
-	if err := tw.Close(); err != nil {
-		panic(err)
-	}
-	if err := gzw.Close(); err != nil {
-		panic(err)
-	}
-}
-
 func checkFile(t *testing.T, dlURL string, files map[string]*task.WebsiteFile, filename string, meta *task.WebsiteFile, check func(*testing.T, []byte)) {
 	t.Run(filename, func(t *testing.T) {
 		f, ok := files[filename]
@@ -558,36 +508,16 @@
 	})
 }
 
-type fakeGerrit struct {
-	changesCreated int
-	createdTags    map[string]string
-	wantReviewers  []string
-	task.GerritClient
+type reviewerCheckGerrit struct {
+	wantReviewers []string
+	*task.FakeGerrit
 }
 
-func (g *fakeGerrit) CreateAutoSubmitChange(ctx context.Context, input gerrit.ChangeInput, reviewers []string, contents map[string]string) (string, error) {
-	g.changesCreated++
+func (g *reviewerCheckGerrit) CreateAutoSubmitChange(ctx context.Context, input gerrit.ChangeInput, reviewers []string, contents map[string]string) (string, error) {
 	if diff := cmp.Diff(g.wantReviewers, reviewers, cmpopts.EquateEmpty()); diff != "" {
 		return "", fmt.Errorf("unexpected reviewers for CL: %v", diff)
 	}
-	return "fake~12345", nil
-}
-
-func (g *fakeGerrit) Submitted(ctx context.Context, changeID, baseCommit string) (string, bool, error) {
-	return "fakehash", true, nil
-}
-
-func (g *fakeGerrit) ListTags(ctx context.Context, project string) ([]string, error) {
-	return []string{"go1.17"}, nil
-}
-
-func (g *fakeGerrit) Tag(ctx context.Context, project, tag, commit string) error {
-	g.createdTags[tag] = commit
-	return nil
-}
-
-func (g *fakeGerrit) ReadBranchHead(ctx context.Context, project, branch string) (string, error) {
-	return fmt.Sprintf("fake HEAD commit for %v/%v", project, branch), nil
+	return g.FakeGerrit.CreateAutoSubmitChange(ctx, input, reviewers, contents)
 }
 
 type fakeGitHub struct {
@@ -609,186 +539,6 @@
 	return nil, nil, nil
 }
 
-type fakeBuildlets struct {
-	t       *testing.T
-	dir     string
-	httpURL string
-
-	mu     sync.Mutex
-	nextID int
-	logs   map[string][]*[]string
-}
-
-func (b *fakeBuildlets) createBuildlet(_ context.Context, kind string) (buildlet.RemoteClient, error) {
-	b.mu.Lock()
-	buildletDir := filepath.Join(b.dir, kind, fmt.Sprint(b.nextID))
-	logs := &[]string{}
-	b.nextID++
-	b.logs[kind] = append(b.logs[kind], logs)
-	b.mu.Unlock()
-	logf := func(format string, args ...interface{}) {
-		line := fmt.Sprintf(format, args...)
-		line = strings.ReplaceAll(line, buildletDir, "$WORK")
-		*logs = append(*logs, line)
-	}
-	logf("--- create buildlet ---")
-
-	return &fakeBuildlet{
-		t:       b.t,
-		kind:    kind,
-		dir:     buildletDir,
-		httpURL: b.httpURL,
-		logf:    logf,
-	}, nil
-}
-
-type fakeBuildlet struct {
-	buildlet.Client
-	t       *testing.T
-	kind    string
-	dir     string
-	httpURL string
-	logf    func(string, ...interface{})
-	closed  bool
-}
-
-func (b *fakeBuildlet) Close() error {
-	if !b.closed {
-		b.logf("--- destroy buildlet ---")
-		b.closed = true
-	}
-	return nil
-}
-
-func (b *fakeBuildlet) Exec(ctx context.Context, cmd string, opts buildlet.ExecOpts) (remoteErr error, execErr error) {
-	b.logf("exec %v %v\n\twd %q env %v", cmd, opts.Args, opts.Dir, opts.ExtraEnv)
-	absCmd := filepath.Join(b.dir, cmd)
-retry:
-	c := exec.CommandContext(ctx, absCmd, opts.Args...)
-	c.Env = append(os.Environ(), opts.ExtraEnv...)
-	buf := &bytes.Buffer{}
-	var w io.Writer = buf
-	if opts.Output != nil {
-		w = io.MultiWriter(w, opts.Output)
-	}
-	c.Stdout = w
-	c.Stderr = w
-	if opts.Dir == "" {
-		c.Dir = filepath.Dir(absCmd)
-	} else {
-		c.Dir = filepath.Join(b.dir, opts.Dir)
-	}
-	err := c.Run()
-	// Work around Unix foolishness. See go.dev/issue/22315.
-	if err != nil && strings.Contains(err.Error(), "text file busy") {
-		time.Sleep(100 * time.Millisecond)
-		goto retry
-	}
-	if err != nil {
-		return nil, fmt.Errorf("command %v %v failed: %v output: %q", cmd, opts.Args, err, buf.String())
-	}
-	return nil, nil
-}
-
-func (b *fakeBuildlet) GetTar(ctx context.Context, dir string) (io.ReadCloser, error) {
-	b.logf("get tar of %q", dir)
-	buf := &bytes.Buffer{}
-	zw := gzip.NewWriter(buf)
-	tw := tar.NewWriter(zw)
-	base := filepath.Join(b.dir, filepath.FromSlash(dir))
-	// Copied pretty much wholesale from buildlet.go.
-	err := filepath.Walk(base, func(path string, fi os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-		rel := strings.TrimPrefix(filepath.ToSlash(strings.TrimPrefix(path, base)), "/")
-		th, err := tar.FileInfoHeader(fi, path)
-		if err != nil {
-			return err
-		}
-		th.Name = rel
-		if fi.IsDir() && !strings.HasSuffix(th.Name, "/") {
-			th.Name += "/"
-		}
-		if th.Name == "/" {
-			return nil
-		}
-		if err := tw.WriteHeader(th); err != nil {
-			return err
-		}
-		if fi.Mode().IsRegular() {
-			f, err := os.Open(path)
-			if err != nil {
-				return err
-			}
-			defer f.Close()
-			if _, err := io.Copy(tw, f); err != nil {
-				return err
-			}
-		}
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	if err := tw.Close(); err != nil {
-		return nil, err
-	}
-	if err := zw.Close(); err != nil {
-		return nil, err
-	}
-	return ioutil.NopCloser(buf), nil
-}
-
-func (b *fakeBuildlet) ListDir(ctx context.Context, dir string, opts buildlet.ListDirOpts, fn func(buildlet.DirEntry)) error {
-	// We call this when something goes wrong, so we need it to "succeed".
-	// It's not worth implementing; return some nonsense.
-	fn(buildlet.DirEntry{
-		Line: "ListDir is silently unimplemented, sorry",
-	})
-	return nil
-}
-
-func (b *fakeBuildlet) Put(ctx context.Context, r io.Reader, path string, mode os.FileMode) error {
-	b.logf("write file %q with mode %0o", path, mode)
-	f, err := os.OpenFile(filepath.Join(b.dir, path), os.O_CREATE|os.O_RDWR, mode)
-	if err != nil {
-		return err
-	}
-	defer f.Close()
-
-	if _, err := io.Copy(f, r); err != nil {
-		return err
-	}
-	return f.Close()
-}
-
-func (b *fakeBuildlet) PutTar(ctx context.Context, r io.Reader, dir string) error {
-	b.logf("put tar to %q", dir)
-	return untar.Untar(r, filepath.Join(b.dir, dir))
-}
-
-func (b *fakeBuildlet) PutTarFromURL(ctx context.Context, tarURL string, dir string) error {
-	b.logf("put tar from %v to %q", tarURL, dir)
-	u, err := url.Parse(tarURL)
-	if err != nil {
-		return err
-	}
-	resp, err := http.Get(b.httpURL + u.Path)
-	if err != nil {
-		return err
-	}
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("unexpected status for %q: %v", tarURL, resp.Status)
-	}
-	defer resp.Body.Close()
-	return untar.Untar(resp.Body, filepath.Join(b.dir, dir))
-}
-
-func (b *fakeBuildlet) WorkDir(ctx context.Context) (string, error) {
-	return b.dir, nil
-}
-
 type verboseListener struct {
 	t              *testing.T
 	outputListener func(string, interface{})
diff --git a/internal/task/fakes.go b/internal/task/fakes.go
new file mode 100644
index 0000000..0115090
--- /dev/null
+++ b/internal/task/fakes.go
@@ -0,0 +1,432 @@
+package task
+
+import (
+	"archive/tar"
+	"bytes"
+	"compress/gzip"
+	"context"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"golang.org/x/build/buildlet"
+	"golang.org/x/build/gerrit"
+	"golang.org/x/build/internal/untar"
+)
+
+// ServeTarball serves files as a .tar.gz to w, only if path contains pathMatch.
+func ServeTarball(pathMatch string, files map[string]string, w http.ResponseWriter, r *http.Request) {
+	if !strings.Contains(r.URL.Path, pathMatch) {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	gzw := gzip.NewWriter(w)
+	tw := tar.NewWriter(gzw)
+
+	for name, contents := range files {
+		if err := tw.WriteHeader(&tar.Header{
+			Typeflag: tar.TypeReg,
+			Name:     name,
+			Size:     int64(len(contents)),
+			Mode:     0777,
+		}); err != nil {
+			panic(err)
+		}
+		if _, err := tw.Write([]byte(contents)); err != nil {
+			panic(err)
+		}
+	}
+
+	if err := tw.Close(); err != nil {
+		panic(err)
+	}
+	if err := gzw.Close(); err != nil {
+		panic(err)
+	}
+}
+
+func NewFakeBuildlets(t *testing.T, httpServer string) *FakeBuildlets {
+	return &FakeBuildlets{
+		t:       t,
+		dir:     t.TempDir(),
+		httpURL: httpServer,
+		logs:    map[string][]*[]string{},
+	}
+}
+
+type FakeBuildlets struct {
+	t       *testing.T
+	dir     string
+	httpURL string
+
+	mu     sync.Mutex
+	nextID int
+	logs   map[string][]*[]string
+}
+
+func (b *FakeBuildlets) CreateBuildlet(_ context.Context, kind string) (buildlet.RemoteClient, error) {
+	b.mu.Lock()
+	buildletDir := filepath.Join(b.dir, kind, fmt.Sprint(b.nextID))
+	logs := &[]string{}
+	b.nextID++
+	b.logs[kind] = append(b.logs[kind], logs)
+	b.mu.Unlock()
+	logf := func(format string, args ...interface{}) {
+		line := fmt.Sprintf(format, args...)
+		line = strings.ReplaceAll(line, buildletDir, "$WORK")
+		*logs = append(*logs, line)
+	}
+	logf("--- create buildlet ---")
+
+	return &fakeBuildlet{
+		t:       b.t,
+		kind:    kind,
+		dir:     buildletDir,
+		httpURL: b.httpURL,
+		logf:    logf,
+	}, nil
+}
+
+func (b *FakeBuildlets) DumpLogs() {
+	for name, logs := range b.logs {
+		b.t.Logf("%v buildlets:", name)
+		for _, group := range logs {
+			for _, line := range *group {
+				b.t.Log(line)
+			}
+		}
+	}
+}
+
+type fakeBuildlet struct {
+	buildlet.Client
+	t       *testing.T
+	kind    string
+	dir     string
+	httpURL string
+	logf    func(string, ...interface{})
+	closed  bool
+}
+
+func (b *fakeBuildlet) Close() error {
+	if !b.closed {
+		b.logf("--- destroy buildlet ---")
+		b.closed = true
+	}
+	return nil
+}
+
+func (b *fakeBuildlet) Exec(ctx context.Context, cmd string, opts buildlet.ExecOpts) (remoteErr error, execErr error) {
+	b.logf("exec %v %v\n\twd %q env %v", cmd, opts.Args, opts.Dir, opts.ExtraEnv)
+	if !strings.HasPrefix(cmd, "/") && !opts.SystemLevel {
+		cmd = filepath.Join(b.dir, cmd)
+	}
+retry:
+	c := exec.CommandContext(ctx, cmd, opts.Args...)
+	c.Env = append(os.Environ(), opts.ExtraEnv...)
+	buf := &bytes.Buffer{}
+	var w io.Writer = buf
+	if opts.Output != nil {
+		w = io.MultiWriter(w, opts.Output)
+	}
+	c.Stdout = w
+	c.Stderr = w
+	if opts.Dir == "" {
+		c.Dir = filepath.Dir(cmd)
+	} else {
+		c.Dir = filepath.Join(b.dir, opts.Dir)
+	}
+	err := c.Run()
+	// Work around Unix foolishness. See go.dev/issue/22315.
+	if err != nil && strings.Contains(err.Error(), "text file busy") {
+		time.Sleep(100 * time.Millisecond)
+		goto retry
+	}
+	if err != nil {
+		return nil, fmt.Errorf("command %v %v failed: %v output: %q", cmd, opts.Args, err, buf.String())
+	}
+	return nil, nil
+}
+
+func (b *fakeBuildlet) GetTar(ctx context.Context, dir string) (io.ReadCloser, error) {
+	b.logf("get tar of %q", dir)
+	buf := &bytes.Buffer{}
+	zw := gzip.NewWriter(buf)
+	tw := tar.NewWriter(zw)
+	base := filepath.Join(b.dir, filepath.FromSlash(dir))
+	// Copied pretty much wholesale from buildlet.go.
+	err := filepath.Walk(base, func(path string, fi os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		rel := strings.TrimPrefix(filepath.ToSlash(strings.TrimPrefix(path, base)), "/")
+		th, err := tar.FileInfoHeader(fi, path)
+		if err != nil {
+			return err
+		}
+		th.Name = rel
+		if fi.IsDir() && !strings.HasSuffix(th.Name, "/") {
+			th.Name += "/"
+		}
+		if th.Name == "/" {
+			return nil
+		}
+		if err := tw.WriteHeader(th); err != nil {
+			return err
+		}
+		if fi.Mode().IsRegular() {
+			f, err := os.Open(path)
+			if err != nil {
+				return err
+			}
+			defer f.Close()
+			if _, err := io.Copy(tw, f); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	if err := tw.Close(); err != nil {
+		return nil, err
+	}
+	if err := zw.Close(); err != nil {
+		return nil, err
+	}
+	return ioutil.NopCloser(buf), nil
+}
+
+func (b *fakeBuildlet) ListDir(ctx context.Context, dir string, opts buildlet.ListDirOpts, fn func(buildlet.DirEntry)) error {
+	// We call this when something goes wrong, so we need it to "succeed".
+	// It's not worth implementing; return some nonsense.
+	fn(buildlet.DirEntry{
+		Line: "ListDir is silently unimplemented, sorry",
+	})
+	return nil
+}
+
+func (b *fakeBuildlet) Put(ctx context.Context, r io.Reader, path string, mode os.FileMode) error {
+	b.logf("write file %q with mode %0o", path, mode)
+	f, err := os.OpenFile(filepath.Join(b.dir, path), os.O_CREATE|os.O_RDWR, mode)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	if _, err := io.Copy(f, r); err != nil {
+		return err
+	}
+	return f.Close()
+}
+
+func (b *fakeBuildlet) PutTar(ctx context.Context, r io.Reader, dir string) error {
+	b.logf("put tar to %q", dir)
+	return untar.Untar(r, filepath.Join(b.dir, dir))
+}
+
+func (b *fakeBuildlet) PutTarFromURL(ctx context.Context, tarURL string, dir string) error {
+	url, err := url.Parse(tarURL)
+	if err != nil {
+		return err
+	}
+	rewritten := url.String()
+	if !strings.Contains(url.Host, "localhost") && !strings.Contains(url.Host, "127.0.0.1") {
+		rewritten = b.httpURL + url.Path
+	}
+	b.logf("put tar from %v (rewritten to %v) to %q", tarURL, rewritten, dir)
+
+	resp, err := http.Get(rewritten)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("unexpected status for %q: %v", tarURL, resp.Status)
+	}
+	defer resp.Body.Close()
+	return untar.Untar(resp.Body, filepath.Join(b.dir, dir))
+}
+
+func (b *fakeBuildlet) WorkDir(ctx context.Context) (string, error) {
+	return b.dir, nil
+}
+
+func NewFakeGerrit(t *testing.T, repos ...*FakeRepo) *FakeGerrit {
+	result := &FakeGerrit{
+		repos: map[string]*FakeRepo{},
+	}
+	server := httptest.NewServer(http.HandlerFunc(result.serveHTTP))
+	result.serverURL = server.URL
+	t.Cleanup(server.Close)
+
+	for _, r := range repos {
+		result.repos[r.name] = r
+	}
+	return result
+}
+
+type FakeGerrit struct {
+	repos     map[string]*FakeRepo
+	serverURL string
+}
+
+type FakeRepo struct {
+	t       *testing.T
+	name    string
+	seq     int
+	history []string // oldest to newest
+	content map[string]map[string]string
+	tags    map[string]string
+}
+
+func NewFakeRepo(t *testing.T, name string) *FakeRepo {
+	return &FakeRepo{
+		t:       t,
+		name:    name,
+		content: map[string]map[string]string{},
+		tags:    map[string]string{},
+	}
+}
+
+func (repo *FakeRepo) Commit(contents map[string]string) string {
+	rev := fmt.Sprintf("%v~%v", repo.name, repo.seq)
+	repo.seq++
+
+	newContent := map[string]string{}
+	if len(repo.history) != 0 {
+		for k, v := range repo.content[repo.history[len(repo.history)-1]] {
+			newContent[k] = v
+		}
+	}
+	for k, v := range contents {
+		newContent[k] = v
+	}
+	repo.content[rev] = newContent
+	repo.history = append(repo.history, rev)
+	return rev
+}
+
+func (repo *FakeRepo) Tag(tag, commit string) {
+	if _, ok := repo.content[commit]; !ok {
+		repo.t.Fatalf("commit %q does not exist on repo %q", commit, repo.name)
+	}
+	if _, ok := repo.tags[tag]; ok {
+		repo.t.Fatalf("tag %q already exists on repo %q", commit, repo.name)
+	}
+	repo.tags[tag] = commit
+}
+
+var _ GerritClient = (*FakeGerrit)(nil)
+
+func (g *FakeGerrit) ListProjects(ctx context.Context) ([]string, error) {
+	var names []string
+	for k := range g.repos {
+		names = append(names, k)
+	}
+	return names, nil
+}
+
+func (g *FakeGerrit) repo(name string) (*FakeRepo, error) {
+	if r, ok := g.repos[name]; ok {
+		return r, nil
+	} else {
+		return nil, fmt.Errorf("no such repo %v: %w", name, gerrit.ErrResourceNotExist)
+	}
+}
+
+func (g *FakeGerrit) ReadBranchHead(ctx context.Context, project, branch string) (string, error) {
+	repo, err := g.repo(project)
+	if err != nil {
+		return "", err
+	}
+	return repo.history[len(repo.history)-1], nil
+}
+
+func (g *FakeGerrit) ReadFile(ctx context.Context, project string, commit string, file string) ([]byte, error) {
+	repo, err := g.repo(project)
+	if err != nil {
+		return nil, err
+	}
+	content := repo.content[commit][file]
+	if content == "" {
+		return nil, fmt.Errorf("commit/file not found %v at %v: %w", file, commit, gerrit.ErrResourceNotExist)
+	}
+	return []byte(content), nil
+}
+
+func (g *FakeGerrit) ListTags(ctx context.Context, project string) ([]string, error) {
+	repo, err := g.repo(project)
+	if err != nil {
+		return nil, err
+	}
+	var tags []string
+	for k := range repo.tags {
+		tags = append(tags, k)
+	}
+	return tags, nil
+}
+
+func (g *FakeGerrit) GetTag(ctx context.Context, project string, tag string) (gerrit.TagInfo, error) {
+	repo, err := g.repo(project)
+	if err != nil {
+		return gerrit.TagInfo{}, err
+	}
+	if commit, ok := repo.tags[tag]; ok {
+		return gerrit.TagInfo{Revision: commit}, nil
+	} else {
+		return gerrit.TagInfo{}, fmt.Errorf("tag not found: %w", gerrit.ErrResourceNotExist)
+	}
+}
+
+func (g *FakeGerrit) CreateAutoSubmitChange(ctx context.Context, input gerrit.ChangeInput, reviewers []string, contents map[string]string) (string, error) {
+	repo, err := g.repo(input.Project)
+	if err != nil {
+		return "", err
+	}
+	commit := repo.Commit(contents)
+	return "cl_" + commit, nil
+}
+
+func (g *FakeGerrit) Submitted(ctx context.Context, changeID, baseCommit string) (string, bool, error) {
+	return strings.TrimPrefix(changeID, "cl_"), true, nil
+}
+
+func (g *FakeGerrit) Tag(ctx context.Context, project, tag, commit string) error {
+	repo, err := g.repo(project)
+	if err != nil {
+		return err
+	}
+	repo.Tag(tag, commit)
+	return nil
+}
+
+func (g *FakeGerrit) GerritURL() string {
+	return g.serverURL
+}
+
+func (g *FakeGerrit) serveHTTP(w http.ResponseWriter, r *http.Request) {
+	parts := strings.Split(r.URL.Path, "/")
+	if len(parts) != 4 {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	repo, err := g.repo(parts[1])
+	if err != nil {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	rev := strings.TrimSuffix(parts[3], ".tar.gz")
+	ServeTarball("", repo.content[rev], w, r)
+}
diff --git a/internal/task/milestones_test.go b/internal/task/milestones_test.go
index 68b30aa..5952d3b 100644
--- a/internal/task/milestones_test.go
+++ b/internal/task/milestones_test.go
@@ -23,7 +23,7 @@
 func TestMilestones(t *testing.T) {
 	ctx := &workflow.TaskContext{
 		Context: context.Background(),
-		Logger:  &testLogger{t},
+		Logger:  &testLogger{t, ""},
 	}
 
 	if !*flagRun {
@@ -142,11 +142,3 @@
 	})
 	return normal, blocker, err
 }
-
-type testLogger struct {
-	t *testing.T
-}
-
-func (l *testLogger) Printf(format string, v ...interface{}) {
-	l.t.Logf("LOG: %s", fmt.Sprintf(format, v...))
-}
diff --git a/internal/task/tagx.go b/internal/task/tagx.go
index 59f745f..ea74859 100644
--- a/internal/task/tagx.go
+++ b/internal/task/tagx.go
@@ -21,8 +21,10 @@
 )
 
 type TagXReposTasks struct {
-	Gerrit         GerritClient
-	CreateBuildlet func(context.Context, string) (buildlet.RemoteClient, error)
+	Gerrit           GerritClient
+	GerritURL        string
+	CreateBuildlet   func(context.Context, string) (buildlet.RemoteClient, error)
+	LatestGoBinaries func(context.Context) (string, error)
 }
 
 func (x *TagXReposTasks) NewDefinition() *wf.Definition {
@@ -248,7 +250,7 @@
 }
 
 func (x *TagXReposTasks) UpdateGoMod(ctx *wf.TaskContext, repo TagRepo, deps []TagRepo, commit string) (UpdatedModSum, error) {
-	binaries, err := latestGoBinaries(ctx)
+	binaries, err := x.LatestGoBinaries(ctx)
 	if err != nil {
 		return UpdatedModSum{}, err
 	}
@@ -261,8 +263,8 @@
 	if err := bc.PutTarFromURL(ctx, binaries, ""); err != nil {
 		return UpdatedModSum{}, err
 	}
-	archive := fmt.Sprintf("https://go.googlesource.com/%v/+archive/%v.tar.gz", repo.Name, commit)
-	if err := bc.PutTarFromURL(ctx, archive, "repo"); err != nil {
+	tarURL := fmt.Sprintf("%s/%s/+archive/%s.tar.gz", x.GerritURL, repo.Name, commit)
+	if err := bc.PutTarFromURL(ctx, tarURL, "repo"); err != nil {
 		return UpdatedModSum{}, err
 	}
 
@@ -322,7 +324,7 @@
 	return UpdatedModSum{mod.String(), sum.String()}, nil
 }
 
-func latestGoBinaries(ctx *wf.TaskContext) (string, error) {
+func LatestGoBinaries(ctx context.Context) (string, error) {
 	resp, err := ctxhttp.Get(ctx, http.DefaultClient, "https://go.dev/dl/?mode=json")
 	if err != nil {
 		return "", err
@@ -347,9 +349,6 @@
 }
 
 func (x *TagXReposTasks) MailGoMod(ctx *wf.TaskContext, repo string, gomod UpdatedModSum) (string, error) {
-	if dryRun {
-		return "", fmt.Errorf("nope")
-	}
 	return x.Gerrit.CreateAutoSubmitChange(ctx, gerrit.ChangeInput{
 		Project: repo,
 		Branch:  "master",
@@ -364,8 +363,6 @@
 	return nil
 }
 
-const dryRun = true
-
 // MaybeTag tags repo at commit with the next version, unless commit is already
 // the latest tagged version. repo is returned with Version populated.
 func (x *TagXReposTasks) MaybeTag(ctx *wf.TaskContext, repo TagRepo, commit string) (TagRepo, error) {
@@ -380,9 +377,8 @@
 			highestRelease = tag
 		}
 	}
-	if highestRelease == "" || dryRun {
-		repo.Version = "latest"
-		return repo, nil
+	if highestRelease == "" {
+		return TagRepo{}, fmt.Errorf("no semver tags found in %v", tags)
 	}
 
 	tagInfo, err := x.Gerrit.GetTag(ctx, repo.Name, highestRelease)
diff --git a/internal/task/tagx_test.go b/internal/task/tagx_test.go
index 4a3e5aa..e19083b 100644
--- a/internal/task/tagx_test.go
+++ b/internal/task/tagx_test.go
@@ -3,10 +3,17 @@
 import (
 	"context"
 	"flag"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"runtime"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/uuid"
 	"golang.org/x/build/gerrit"
 	"golang.org/x/build/internal/workflow"
 )
@@ -25,7 +32,7 @@
 	}
 	ctx := &workflow.TaskContext{
 		Context: context.Background(),
-		Logger:  &testLogger{t},
+		Logger:  &testLogger{t, ""},
 	}
 	repos, err := tasks.SelectRepos(ctx)
 	if err != nil {
@@ -116,3 +123,145 @@
 		}
 	}
 }
+
+const fakeGo = `#!/bin/bash -eu
+
+case "$1" in
+"get")
+  ls go.mod go.sum >/dev/null
+  for i in "${@:2}"; do
+    echo -e "// pretend we've upgraded to $i" >> go.mod
+    echo "$i h1:asdasd" | tr '@' ' ' >> go.sum
+  done
+  ;;
+"mod")
+  ls go.mod go.sum >/dev/null
+  echo "tidied!" >> go.mod
+  ;;
+*)
+  echo unexpected command $@
+  exit 1
+  ;;
+esac
+`
+
+func TestTagXRepos(t *testing.T) {
+	if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
+		t.Skip("Requires bash shell scripting support.")
+	}
+
+	goServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ServeTarball("dl/go1.19.linux-amd64.tar.gz", map[string]string{
+			"go/bin/go": fakeGo,
+		}, w, r)
+	}))
+	t.Cleanup(goServer.Close)
+
+	fakeBuildlets := NewFakeBuildlets(t, "")
+
+	sys := NewFakeRepo(t, "sys")
+	sys1 := sys.Commit(map[string]string{
+		"go.mod": "module golang.org/x/sys\n",
+		"go.sum": "\n",
+	})
+	sys.Tag("v0.1.0", sys1)
+	sys2 := sys.Commit(map[string]string{
+		"main.go": "package main",
+	})
+	mod := NewFakeRepo(t, "mod")
+	mod1 := mod.Commit(map[string]string{
+		"go.mod": "module golang.org/x/mod\n",
+		"go.sum": "\n",
+	})
+	mod.Tag("v1.0.0", mod1)
+	tools := NewFakeRepo(t, "tools")
+	tools1 := tools.Commit(map[string]string{
+		"go.mod": "module golang.org/x/tools\nrequire golang.org/x/mod v1.0.0\nrequire golang.org/x/sys v0.1.0\n",
+		"go.sum": "\n",
+	})
+	tools.Tag("v1.1.5", tools1)
+	fakeGerrit := NewFakeGerrit(t, sys, mod, tools)
+	tasks := &TagXReposTasks{
+		Gerrit:         fakeGerrit,
+		GerritURL:      fakeGerrit.GerritURL(),
+		CreateBuildlet: fakeBuildlets.CreateBuildlet,
+		LatestGoBinaries: func(context.Context) (string, error) {
+			return goServer.URL + "/dl/go1.19.linux-amd64.tar.gz", nil
+		},
+	}
+	wd := tasks.NewDefinition()
+	w, err := workflow.Start(wd, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	_, err = w.Run(ctx, &verboseListener{t: t})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tag, err := fakeGerrit.GetTag(ctx, "sys", "v0.2.0")
+	if err != nil {
+		t.Fatalf("sys should have been tagged with v0.2.0: %v", err)
+	}
+	if tag.Revision != sys2 {
+		t.Errorf("sys v0.2.0 = %v, want %v", tag.Revision, sys2)
+	}
+
+	tags, err := fakeGerrit.ListTags(ctx, "mod")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !reflect.DeepEqual(tags, []string{"v1.0.0"}) {
+		t.Errorf("mod has tags %v, wanted only v1.0.0", tags)
+	}
+
+	tag, err = fakeGerrit.GetTag(ctx, "tools", "v1.2.0")
+	if err != nil {
+		t.Fatalf("tools should have been tagged with v1.2.0: %v", err)
+	}
+	goMod, err := fakeGerrit.ReadFile(ctx, "tools", tag.Revision, "go.mod")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(string(goMod), "sys@v0.2.0") || !strings.Contains(string(goMod), "mod@v1.0.0") {
+		t.Errorf("tools should use sys v0.2.0 and mod v1.0.0. go.mod: %v", string(goMod))
+	}
+	if !strings.Contains(string(goMod), "tidied!") {
+		t.Error("tools go.mod should be tidied")
+	}
+}
+
+type verboseListener struct {
+	t              *testing.T
+	outputListener func(string, interface{})
+}
+
+func (l *verboseListener) TaskStateChanged(_ uuid.UUID, _ string, st *workflow.TaskState) error {
+	switch {
+	case !st.Finished:
+		l.t.Logf("task %-10v: started", st.Name)
+	case st.Error != "":
+		l.t.Logf("task %-10v: error: %v", st.Name, st.Error)
+	default:
+		l.t.Logf("task %-10v: done: %v", st.Name, st.Result)
+		if l.outputListener != nil {
+			l.outputListener(st.Name, st.Result)
+		}
+	}
+	return nil
+}
+
+func (l *verboseListener) Logger(_ uuid.UUID, task string) workflow.Logger {
+	return &testLogger{t: l.t, task: task}
+}
+
+type testLogger struct {
+	t    *testing.T
+	task string // Optional.
+}
+
+func (l *testLogger) Printf(format string, v ...interface{}) {
+	l.t.Logf("task %-10v: LOG: %s", l.task, fmt.Sprintf(format, v...))
+}
diff --git a/internal/task/version_test.go b/internal/task/version_test.go
index 512973e..6d035cb 100644
--- a/internal/task/version_test.go
+++ b/internal/task/version_test.go
@@ -25,7 +25,7 @@
 	}
 	ctx := &workflow.TaskContext{
 		Context: context.Background(),
-		Logger:  &testLogger{t},
+		Logger:  &testLogger{t, ""},
 	}
 
 	versions := map[ReleaseKind]string{}
@@ -53,7 +53,7 @@
 	}
 	ctx := &workflow.TaskContext{
 		Context: context.Background(),
-		Logger:  &testLogger{t},
+		Logger:  &testLogger{t, ""},
 	}
 	versions := map[ReleaseKind]string{}
 	for kind := ReleaseKind(1); kind <= KindPrevMinor; kind++ {
@@ -95,7 +95,7 @@
 	}
 	ctx := &workflow.TaskContext{
 		Context: context.Background(),
-		Logger:  &testLogger{t},
+		Logger:  &testLogger{t, ""},
 	}
 
 	changeID, err := tasks.CreateAutoSubmitVersionCL(ctx, "master", nil, "version string")