Add weekly CIPD package roller

Change-Id: I402c937f2d8d89640059648122e01e0254be89d0
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/254579
Commit-Queue: Eric Boren <borenet@google.com>
Reviewed-by: Ravi Mistry <rmistry@google.com>
diff --git a/cipd.ensure b/cipd.ensure
index c10d810..ff5a70c 100644
--- a/cipd.ensure
+++ b/cipd.ensure
@@ -1,5 +1,4 @@
 # This file specifies the CIPD packages and versions used in this repo.
-# TODO(borenet): These versions should be auto-rolled.
 
 # The CIPD server to use.
 $ServiceURL https://chrome-infra-packages.appspot.com/
diff --git a/go/cipd/cipd.go b/go/cipd/cipd.go
index f495d20..39b9e4c 100644
--- a/go/cipd/cipd.go
+++ b/go/cipd/cipd.go
@@ -9,12 +9,15 @@
 import (
 	"context"
 	"fmt"
+	"io"
 	"net/http"
 
 	"go.chromium.org/luci/cipd/client/cipd"
+	"go.chromium.org/luci/cipd/client/cipd/ensure"
 	"go.chromium.org/luci/cipd/common"
 	"go.skia.org/infra/go/skerr"
 	"go.skia.org/infra/go/sklog"
+	"go.skia.org/infra/go/util"
 )
 
 const (
@@ -65,6 +68,13 @@
 	return fmt.Sprintf("%s:%s:%s", p.Path, p.Name, p.Version)
 }
 
+// PackageSlice is used for sorting packages by name.
+type PackageSlice []*Package
+
+func (s PackageSlice) Len() int           { return len(s) }
+func (s PackageSlice) Less(i, j int) bool { return s[i].Name < s[j].Name }
+func (s PackageSlice) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
+
 // GetPackage returns the definition for the package with the given name, or an
 // error if the package does not exist in the registry.
 func GetPackage(pkg string) (*Package, error) {
@@ -106,6 +116,34 @@
 	return cipdClient.Ensure(ctx, packages...)
 }
 
+// ParseEnsureFile parses a CIPD ensure file and returns a slice of Packages.
+func ParseEnsureFile(file string) ([]*Package, error) {
+	var ensureFile *ensure.File
+	if err := util.WithReadFile(file, func(r io.Reader) error {
+		f, err := ensure.ParseFile(r)
+		if err == nil {
+			ensureFile = f
+		}
+		return err
+	}); err != nil {
+		return nil, skerr.Wrapf(err, "Failed to parse CIPD ensure file %s", file)
+	}
+	var rv []*Package
+	for subdir, pkgSlice := range ensureFile.PackagesBySubdir {
+		if subdir == "" {
+			subdir = "."
+		}
+		for _, pkg := range pkgSlice {
+			rv = append(rv, &Package{
+				Path:    subdir,
+				Name:    pkg.PackageTemplate,
+				Version: pkg.UnresolvedVersion,
+			})
+		}
+	}
+	return rv, nil
+}
+
 // CIPDClient is the interface for interactions with the CIPD API.
 type CIPDClient interface {
 	cipd.Client
diff --git a/go/cipd/gen_versions.go b/go/cipd/gen_versions.go
index b2fa657..e5c57f8 100644
--- a/go/cipd/gen_versions.go
+++ b/go/cipd/gen_versions.go
@@ -18,7 +18,6 @@
 	"sort"
 	"strings"
 
-	"go.chromium.org/luci/cipd/client/cipd/ensure"
 	"go.skia.org/infra/go/cipd"
 	"go.skia.org/infra/go/exec"
 	"go.skia.org/infra/go/sklog"
@@ -41,74 +40,50 @@
 	pkgDir := path.Dir(filename)
 	rootDir := path.Join(pkgDir, "..", "..")
 
+	// Read packages from cipd.ensure.
+	pkgs, err := cipd.ParseEnsureFile(filepath.Join(rootDir, "cipd.ensure"))
+	if err != nil {
+		sklog.Fatal(err)
+	}
+
 	// List the assets.
 	assetsDir := path.Join(rootDir, "infra", "bots", "assets")
 	entries, err := ioutil.ReadDir(assetsDir)
 	if err != nil {
 		sklog.Fatal(err)
 	}
-	pkgs := map[string]*cipd.Package{}
 	for _, e := range entries {
 		if e.IsDir() {
 			contents, err := ioutil.ReadFile(path.Join(assetsDir, e.Name(), "VERSION"))
 			if err == nil {
 				name := e.Name()
 				fullName := fmt.Sprintf("skia/bots/%s", name)
-				pkgs[fullName] = &cipd.Package{
+				pkgs = append(pkgs, &cipd.Package{
 					Path:    name,
 					Name:    fullName,
 					Version: cipd.VersionTag(strings.TrimSpace(string(contents))),
-				}
+				})
 			} else if !os.IsNotExist(err) {
 				sklog.Fatal(err)
 			}
 		}
 	}
 
-	// Read packages from cipd.ensure.
-	var ensureFile *ensure.File
-	if err := util.WithReadFile(filepath.Join(rootDir, "cipd.ensure"), func(r io.Reader) error {
-		f, err := ensure.ParseFile(r)
-		if err == nil {
-			ensureFile = f
-		}
-		return err
-	}); err != nil {
-		sklog.Fatal(err)
-	}
-	for subdir, pkgSlice := range ensureFile.PackagesBySubdir {
-		if subdir == "" {
-			subdir = "."
-		}
-		for _, pkg := range pkgSlice {
-			pkgs[pkg.PackageTemplate] = &cipd.Package{
-				Path:    subdir,
-				Name:    pkg.PackageTemplate,
-				Version: pkg.UnresolvedVersion,
-			}
-		}
-	}
-
 	// Write the file.
-	pkgNames := make([]string, 0, len(pkgs))
-	for name := range pkgs {
-		pkgNames = append(pkgNames, name)
-	}
-	sort.Strings(pkgNames)
+	sort.Sort(cipd.PackageSlice(pkgs))
 	targetFile := path.Join(pkgDir, TARGET_FILE)
 	if err := util.WithWriteFile(targetFile, func(w io.Writer) error {
 		_, err := w.Write([]byte(HEADER))
 		if err != nil {
 			return err
 		}
-		for _, name := range pkgNames {
-			pkg := pkgs[name]
+		for _, pkg := range pkgs {
 			_, err := fmt.Fprintf(w, fmt.Sprintf(`	"%s": &Package{
 		Path: "%s",
 		Name: "%s",
 		Version: "%s",
 	},
-`, name, pkg.Path, pkg.Name, pkg.Version))
+`, pkg.Name, pkg.Path, pkg.Name, pkg.Version))
 			if err != nil {
 				return err
 			}
diff --git a/infra/bots/gen_tasks.go b/infra/bots/gen_tasks.go
index d8668f5..983f110 100644
--- a/infra/bots/gen_tasks.go
+++ b/infra/bots/gen_tasks.go
@@ -54,6 +54,7 @@
 	// Top-level list of all Jobs to run at each commit.
 	JOBS = []string{
 		"Housekeeper-Nightly-UpdateGoDeps",
+		"Housekeeper-Weekly-UpdateCIPDPackages",
 		"Housekeeper-OnDemand-Presubmit",
 		"Infra-PerCommit-Build",
 		"Infra-PerCommit-Small",
@@ -401,6 +402,41 @@
 	return name
 }
 
+func updateCIPDPackages(b *specs.TasksCfgBuilder, name string) string {
+	cipd := append([]*specs.CipdPackage{}, specs.CIPD_PKGS_GIT...)
+	cipd = append(cipd, b.MustGetCipdPackageFromAsset("protoc"))
+
+	machineType := MACHINE_TYPE_MEDIUM
+	t := &specs.TaskSpec{
+		CipdPackages: cipd,
+		Command: []string{
+			"./roll_cipd_packages",
+			"--project_id", "skia-swarming-bots",
+			"--task_id", specs.PLACEHOLDER_TASK_ID,
+			"--task_name", name,
+			"--workdir", ".",
+			"--gerrit_project", "buildbot",
+			"--gerrit_url", "https://skia-review.googlesource.com",
+			"--repo", specs.PLACEHOLDER_REPO,
+			"--reviewers", "borenet@google.com",
+			"--revision", specs.PLACEHOLDER_REVISION,
+			"--patch_issue", specs.PLACEHOLDER_ISSUE,
+			"--patch_set", specs.PLACEHOLDER_PATCHSET,
+			"--patch_server", specs.PLACEHOLDER_CODEREVIEW_SERVER,
+			"--alsologtostderr",
+		},
+		Dependencies: []string{buildTaskDrivers(b, "Linux", "x86_64")},
+		Dimensions:   linuxGceDimensions(machineType),
+		EnvPrefixes: map[string][]string{
+			"PATH": {"cipd_bin_packages", "cipd_bin_packages/bin", "go/go/bin"},
+		},
+		Isolate:        "empty.isolate",
+		ServiceAccount: SERVICE_ACCOUNT_RECREATE_SKPS,
+	}
+	b.MustAddTask(name, t)
+	return name
+}
+
 // process generates Tasks and Jobs for the given Job name.
 func process(b *specs.TasksCfgBuilder, name string) {
 	var priority float64 // Leave as default for most jobs.
@@ -412,6 +448,9 @@
 	} else if strings.Contains(name, "UpdateGoDeps") {
 		// Update Go deps bot.
 		deps = append(deps, updateGoDeps(b, name))
+	} else if strings.Contains(name, "UpdateCIPDPackages") {
+		// Update CIPD packages bot.
+		deps = append(deps, updateCIPDPackages(b, name))
 	} else {
 		// Infra tests.
 		if strings.Contains(name, "Infra-PerCommit") {
diff --git a/infra/bots/task_drivers/roll_cipd_packages/roll_cipd_packages.go b/infra/bots/task_drivers/roll_cipd_packages/roll_cipd_packages.go
new file mode 100644
index 0000000..98045ee
--- /dev/null
+++ b/infra/bots/task_drivers/roll_cipd_packages/roll_cipd_packages.go
@@ -0,0 +1,229 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"path"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/go/cipd"
+	"go.skia.org/infra/go/gitiles"
+	"go.skia.org/infra/go/sklog"
+	"go.skia.org/infra/go/vcsinfo"
+	"go.skia.org/infra/task_driver/go/lib/auth_steps"
+	"go.skia.org/infra/task_driver/go/lib/checkout"
+	"go.skia.org/infra/task_driver/go/lib/gerrit_steps"
+	"go.skia.org/infra/task_driver/go/lib/golang"
+	"go.skia.org/infra/task_driver/go/lib/os_steps"
+	"go.skia.org/infra/task_driver/go/td"
+)
+
+const (
+	// Tag indicating the most recently uploaded version of a CIPD package.
+	TAG_LATEST = "latest"
+
+	// Tag prefixes.
+	TAG_PREFIX_VERSION  = "version:"
+	TAG_PREFIX_REPO     = "git_repository:"
+	TAG_PREFIX_REVISION = "git_revision:"
+)
+
+var (
+	// Required properties for this task.
+	gerritProject = flag.String("gerrit_project", "", "Gerrit project name.")
+	gerritUrl     = flag.String("gerrit_url", "", "URL of the Gerrit server.")
+	projectId     = flag.String("project_id", "", "ID of the Google Cloud project.")
+	reviewers     = flag.String("reviewers", "", "Comma-separated list of emails to review the CL.")
+	taskId        = flag.String("task_id", "", "ID of this task.")
+	taskName      = flag.String("task_name", "", "Name of the task.")
+	workdir       = flag.String("workdir", ".", "Working directory")
+
+	checkoutFlags = checkout.SetupFlags(nil)
+
+	// Optional flags.
+	local  = flag.Bool("local", false, "True if running locally (as opposed to on the bots)")
+	output = flag.String("o", "", "If provided, dump a JSON blob of step data to the given file. Prints to stdout if '-' is given.")
+)
+
+func main() {
+	// Setup.
+	ctx := td.StartRun(projectId, taskId, taskName, output, local)
+	defer td.EndRun(ctx)
+
+	rs, err := checkout.GetRepoState(checkoutFlags)
+	if err != nil {
+		td.Fatal(ctx, err)
+	}
+	if *gerritProject == "" {
+		td.Fatalf(ctx, "--gerrit_project is required.")
+	}
+	if *gerritUrl == "" {
+		td.Fatalf(ctx, "--gerrit_url is required.")
+	}
+
+	wd, err := os_steps.Abs(ctx, *workdir)
+	if err != nil {
+		td.Fatal(ctx, err)
+	}
+
+	// Check out the code.
+	co, err := checkout.EnsureGitCheckout(ctx, path.Join(wd, "repo"), rs)
+	if err != nil {
+		td.Fatal(ctx, err)
+	}
+
+	// Setup go.
+	ctx = golang.WithEnv(ctx, wd)
+
+	// Read packages from cipd.ensure.
+	ensureFile := filepath.Join(co.Dir(), "cipd.ensure")
+	var pkgs []*cipd.Package
+	if err := td.Do(ctx, td.Props("Read cipd.ensure").Infra(), func(ctx context.Context) error {
+		pkgs, err = cipd.ParseEnsureFile(ensureFile)
+		if err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		td.Fatal(ctx, err)
+	}
+	sort.Sort(cipd.PackageSlice(pkgs))
+
+	// Find the latest versions of the desired packages.
+	c, err := auth_steps.InitHttpClient(ctx, *local, auth.SCOPE_USERINFO_EMAIL, auth.SCOPE_GERRIT)
+	if err != nil {
+		td.Fatal(ctx, err)
+	}
+	var cc *cipd.Client
+	if err := td.Do(ctx, td.Props("Create CIPD client").Infra(), func(ctx context.Context) error {
+		cc, err = cipd.NewClient(c, *workdir)
+		if err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		td.Fatal(ctx, err)
+	}
+	newVersions := make(map[*cipd.Package]string, len(pkgs))
+	if err := td.Do(ctx, td.Props("Get latest package versions").Infra(), func(ctx context.Context) error {
+		for _, pkg := range pkgs {
+			// Fill in the placeholders.
+			name := pkg.Name
+			for placeholder, val := range map[string]string{
+				"arch":     "amd64",
+				"os":       "linux",
+				"platform": "linux-amd64",
+			} {
+				name = strings.ReplaceAll(name, fmt.Sprintf("${%s}", placeholder), val)
+			}
+			if err := td.Do(ctx, td.Props(fmt.Sprintf("Find latest %s", name)).Infra(), func(ctx context.Context) error {
+				// Find the latest version of the package.
+				pin, err := cc.ResolveVersion(ctx, name, TAG_LATEST)
+				if err != nil {
+					return err
+				}
+				// Retrieve details of the package instance, including the full
+				// set of refs and tags.
+				desc, err := cc.Describe(ctx, name, pin.InstanceID)
+				if err != nil {
+					return err
+				}
+				newVersionTag := ""
+				tags := make([]string, 0, len(desc.Tags))
+				var repos []string
+				var revs []string
+				for _, tag := range desc.Tags {
+					tags = append(tags, tag.Tag)
+
+					// First preference: "version"
+					if strings.HasPrefix(tag.Tag, TAG_PREFIX_VERSION) {
+						newVersionTag = tag.Tag
+						break
+					}
+					// Fall back to choosing the most recent
+					// tagged commit based on repo+revision.
+					if strings.HasPrefix(tag.Tag, TAG_PREFIX_REPO) {
+						repos = append(repos, strings.TrimPrefix(tag.Tag, TAG_PREFIX_REPO))
+					}
+					if strings.HasPrefix(tag.Tag, TAG_PREFIX_REVISION) {
+						revs = append(revs, strings.TrimPrefix(tag.Tag, TAG_PREFIX_REVISION))
+					}
+				}
+				if newVersionTag == "" {
+					// If more than one repo is listed, we need to match the
+					// git_revision to the correct repo in order to obtain the
+					// timestamp.
+					// TODO(borenet): Is there ever more than one git_repository?
+					commits := make([]*vcsinfo.LongCommit, 0, len(revs))
+					for _, repo := range repos {
+						r := gitiles.NewRepo(repo, c)
+						for _, rev := range revs {
+							// Ignore any error, in case we're looking
+							// at the wrong repo.
+							details, err := r.Details(ctx, rev)
+							if err == nil {
+								// Sanity check.
+								if details.Hash == rev {
+									commits = append(commits, details)
+								} else {
+									sklog.Errorf("Retrieved commit details do not match git_revision tag: expect %q but got %q", rev, details.Hash)
+								}
+							}
+						}
+					}
+					if len(commits) > 0 {
+						// Sort by timestamp, most recent first.
+						sort.Sort(vcsinfo.LongCommitSlice(commits))
+						newVersionTag = TAG_PREFIX_REVISION + commits[0].Hash
+					}
+				}
+				if newVersionTag == "" {
+					return fmt.Errorf("Unable to find a valid version tag in %+v", tags)
+				}
+				newVersions[pkg] = newVersionTag
+				return nil
+			}); err != nil {
+				return err
+			}
+		}
+		return nil
+	}); err != nil {
+		td.Fatal(ctx, err)
+	}
+
+	// Write the new ensure file; we read the original and find-and-replace
+	// the package versions.
+	ensureBytes, err := os_steps.ReadFile(ctx, ensureFile)
+	if err != nil {
+		td.Fatal(ctx, err)
+	}
+	oldLines := strings.Split(string(ensureBytes), "\n")
+	newLines := make([]string, 0, len(oldLines))
+	for _, line := range oldLines {
+		for _, pkg := range pkgs {
+			if strings.HasPrefix(line, pkg.Name) {
+				line = strings.ReplaceAll(line, pkg.Version, newVersions[pkg])
+				break
+			}
+		}
+		newLines = append(newLines, line)
+	}
+	if err := os_steps.WriteFile(ctx, ensureFile, []byte(strings.Join(newLines, "\n")), os.ModePerm); err != nil {
+		td.Fatal(ctx, err)
+	}
+
+	// If we changed anything, upload a CL.
+	g, err := gerrit_steps.Init(ctx, *local, wd, *gerritUrl)
+	if err != nil {
+		td.Fatal(ctx, err)
+	}
+	isTryJob := *local || rs.Issue != ""
+	if err := gerrit_steps.UploadCL(ctx, g, co, *gerritProject, "master", rs.Revision, "Update CIPD Packages", strings.Split(*reviewers, ","), isTryJob); err != nil {
+		td.Fatal(ctx, err)
+	}
+}
diff --git a/infra/bots/task_drivers/update_go_deps/update_go_deps.go b/infra/bots/task_drivers/update_go_deps/update_go_deps.go
index 114c5ea..bc4449c 100644
--- a/infra/bots/task_drivers/update_go_deps/update_go_deps.go
+++ b/infra/bots/task_drivers/update_go_deps/update_go_deps.go
@@ -1,12 +1,10 @@
 package main
 
 import (
-	"context"
 	"flag"
 	"path"
 	"strings"
 
-	"go.skia.org/infra/go/gerrit"
 	"go.skia.org/infra/task_driver/go/lib/checkout"
 	"go.skia.org/infra/task_driver/go/lib/gerrit_steps"
 	"go.skia.org/infra/task_driver/go/lib/golang"
@@ -90,46 +88,12 @@
 	}
 
 	// If we changed anything, upload a CL.
-	diff, err := co.Git(ctx, "diff", "--name-only")
+	g, err := gerrit_steps.Init(ctx, *local, wd, *gerritUrl)
 	if err != nil {
 		td.Fatal(ctx, err)
 	}
-	diff = strings.TrimSpace(diff)
-	modFiles := strings.Split(diff, "\n")
-	if len(modFiles) > 0 && diff != "" {
-		g, err := gerrit_steps.Init(ctx, *local, wd, *gerritUrl)
-		if err != nil {
-			td.Fatal(ctx, err)
-		}
-		if err := td.Do(ctx, td.Props("Upload CL").Infra(), func(ctx context.Context) error {
-			ci, err := gerrit.CreateAndEditChange(ctx, g, *gerritProject, "master", "Update Go deps", rs.Revision, func(ctx context.Context, g gerrit.GerritInterface, ci *gerrit.ChangeInfo) error {
-				for _, f := range modFiles {
-					contents, err := os_steps.ReadFile(ctx, path.Join(co.Dir(), f))
-					if err != nil {
-						return err
-					}
-					if err := g.EditFile(ctx, ci, f, string(contents)); err != nil {
-						return err
-					}
-				}
-				return nil
-			})
-			if err != nil {
-				return err
-			}
-			var labels map[string]int
-			if !*local && rs.Issue == "" {
-				labels = map[string]int{
-					gerrit.CODEREVIEW_LABEL:  gerrit.CODEREVIEW_LABEL_APPROVE,
-					gerrit.COMMITQUEUE_LABEL: gerrit.COMMITQUEUE_LABEL_SUBMIT,
-				}
-			}
-			if err := g.SetReview(ctx, ci, "Ready for review.", labels, strings.Split(*reviewers, ",")); err != nil {
-				return err
-			}
-			return nil
-		}); err != nil {
-			td.Fatal(ctx, err)
-		}
+	isTryJob := *local || rs.Issue != ""
+	if err := gerrit_steps.UploadCL(ctx, g, co, *gerritProject, "master", rs.Revision, "Update Go Deps", strings.Split(*reviewers, ","), isTryJob); err != nil {
+		td.Fatal(ctx, err)
 	}
 }
diff --git a/infra/bots/tasks.json b/infra/bots/tasks.json
index 8b7e3aa..7740a45 100755
--- a/infra/bots/tasks.json
+++ b/infra/bots/tasks.json
@@ -13,6 +13,11 @@
       ],
       "trigger": "on demand"
     },
+    "Housekeeper-Weekly-UpdateCIPDPackages": {
+      "tasks": [
+        "Housekeeper-Weekly-UpdateCIPDPackages"
+      ]
+    },
     "Infra-Experimental-Small-Linux": {
       "tasks": [
         "Infra-Experimental-Small-Linux"
@@ -391,6 +396,77 @@
       "idempotent": true,
       "isolate": "recipes.isolate"
     },
+    "Housekeeper-Weekly-UpdateCIPDPackages": {
+      "cipd_packages": [
+        {
+          "name": "infra/git/${platform}",
+          "path": "cipd_bin_packages",
+          "version": "version:2.23.0.chromium16"
+        },
+        {
+          "name": "infra/tools/git/${platform}",
+          "path": "cipd_bin_packages",
+          "version": "git_revision:0275b342af7f4ef18f4513f80d3b0e5c1bb3fb6c"
+        },
+        {
+          "name": "infra/tools/luci/git-credential-luci/${platform}",
+          "path": "cipd_bin_packages",
+          "version": "git_revision:2c805f1c716f6c5ad2126b27ec88b8585a09481e"
+        },
+        {
+          "name": "skia/bots/protoc",
+          "path": "protoc",
+          "version": "version:0"
+        }
+      ],
+      "command": [
+        "./roll_cipd_packages",
+        "--project_id",
+        "skia-swarming-bots",
+        "--task_id",
+        "<(TASK_ID)",
+        "--task_name",
+        "Housekeeper-Weekly-UpdateCIPDPackages",
+        "--workdir",
+        ".",
+        "--gerrit_project",
+        "buildbot",
+        "--gerrit_url",
+        "https://skia-review.googlesource.com",
+        "--repo",
+        "<(REPO)",
+        "--reviewers",
+        "borenet@google.com",
+        "--revision",
+        "<(REVISION)",
+        "--patch_issue",
+        "<(ISSUE)",
+        "--patch_set",
+        "<(PATCHSET)",
+        "--patch_server",
+        "<(CODEREVIEW_SERVER)",
+        "--alsologtostderr"
+      ],
+      "dependencies": [
+        "Housekeeper-PerCommit-BuildTaskDrivers-Linux-x86_64"
+      ],
+      "dimensions": [
+        "pool:Skia",
+        "os:Debian-9.8",
+        "gpu:none",
+        "cpu:x86-64-Haswell_GCE",
+        "machine_type:n1-standard-16"
+      ],
+      "env_prefixes": {
+        "PATH": [
+          "cipd_bin_packages",
+          "cipd_bin_packages/bin",
+          "go/go/bin"
+        ]
+      },
+      "isolate": "empty.isolate",
+      "service_account": "skia-recreate-skps@skia-swarming-bots.iam.gserviceaccount.com"
+    },
     "Infra-Experimental-Small-Linux": {
       "caches": [
         {
diff --git a/task_driver/go/lib/auth_steps/auth_steps.go b/task_driver/go/lib/auth_steps/auth_steps.go
index 5d02a92..bb31677 100644
--- a/task_driver/go/lib/auth_steps/auth_steps.go
+++ b/task_driver/go/lib/auth_steps/auth_steps.go
@@ -7,8 +7,10 @@
 
 import (
 	"context"
+	"net/http"
 
 	"go.skia.org/infra/go/auth"
+	"go.skia.org/infra/go/httputils"
 	"go.skia.org/infra/task_driver/go/td"
 	"golang.org/x/oauth2"
 )
@@ -26,3 +28,15 @@
 	})
 	return ts, err
 }
+
+func HttpClient(ctx context.Context, ts oauth2.TokenSource) *http.Client {
+	return td.HttpClient(ctx, httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client())
+}
+
+func InitHttpClient(ctx context.Context, local bool, scopes ...string) (*http.Client, error) {
+	ts, err := Init(ctx, local, scopes...)
+	if err != nil {
+		return nil, err
+	}
+	return HttpClient(ctx, ts), nil
+}
diff --git a/task_driver/go/lib/gerrit_steps/gerrit_steps.go b/task_driver/go/lib/gerrit_steps/gerrit_steps.go
index da379b5..e659c23 100644
--- a/task_driver/go/lib/gerrit_steps/gerrit_steps.go
+++ b/task_driver/go/lib/gerrit_steps/gerrit_steps.go
@@ -7,9 +7,13 @@
 
 import (
 	"context"
+	"path"
+	"strings"
 
 	"go.skia.org/infra/go/gerrit"
+	"go.skia.org/infra/go/git"
 	"go.skia.org/infra/task_driver/go/lib/git_steps"
+	"go.skia.org/infra/task_driver/go/lib/os_steps"
 	"go.skia.org/infra/task_driver/go/td"
 )
 
@@ -28,3 +32,47 @@
 	})
 	return rv, err
 }
+
+// UploadCL uploads a CL containing any changes to the given git.Checkout. This
+// is a no-op if there are no changes.
+func UploadCL(ctx context.Context, g gerrit.GerritInterface, co *git.Checkout, project, branch, baseRevision, commitMsg string, reviewers []string, isTryJob bool) error {
+	diff, err := co.Git(ctx, "diff", "--name-only")
+	if err != nil {
+		return err
+	}
+	diff = strings.TrimSpace(diff)
+	modFiles := strings.Split(diff, "\n")
+	if len(modFiles) > 0 && diff != "" {
+		if err := td.Do(ctx, td.Props("Upload CL").Infra(), func(ctx context.Context) error {
+			ci, err := gerrit.CreateAndEditChange(ctx, g, project, branch, commitMsg, baseRevision, func(ctx context.Context, g gerrit.GerritInterface, ci *gerrit.ChangeInfo) error {
+				for _, f := range modFiles {
+					contents, err := os_steps.ReadFile(ctx, path.Join(co.Dir(), f))
+					if err != nil {
+						return err
+					}
+					if err := g.EditFile(ctx, ci, f, string(contents)); err != nil {
+						return err
+					}
+				}
+				return nil
+			})
+			if err != nil {
+				return err
+			}
+			var labels map[string]int
+			if !isTryJob {
+				labels = map[string]int{
+					gerrit.CODEREVIEW_LABEL:  gerrit.CODEREVIEW_LABEL_APPROVE,
+					gerrit.COMMITQUEUE_LABEL: gerrit.COMMITQUEUE_LABEL_SUBMIT,
+				}
+			}
+			if err := g.SetReview(ctx, ci, "Ready for review.", labels, reviewers); err != nil {
+				return err
+			}
+			return nil
+		}); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/task_driver/go/lib/os_steps/os_steps.go b/task_driver/go/lib/os_steps/os_steps.go
index 4b43d28..751527f 100644
--- a/task_driver/go/lib/os_steps/os_steps.go
+++ b/task_driver/go/lib/os_steps/os_steps.go
@@ -72,6 +72,13 @@
 	return rv, err
 }
 
+// WriteFile is a wrapper for ioutil.WriteFile.
+func WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error {
+	return td.Do(ctx, td.Props(fmt.Sprintf("Write %s", path)).Infra(), func(context.Context) error {
+		return ioutil.WriteFile(path, data, perm)
+	})
+}
+
 // Which returns the result of "which <exe>" (or "where <exe>" on Windows).
 func Which(ctx context.Context, exe string) (string, error) {
 	var rv string