[Autoroller] Add new Github CIPD DEPS repo manager

Highlights:
* Added new repo manager github_cipd_deps_repo_manager. It does not require a separate github user for each roller and instead uses a different github branch based on the rollerName.
* Refactored cleanParentWithRemote and createAndSyncParentWithRemote to handle branch name parameter.
* Refactored license scripts pre-upload step and made a new FlutterLicenseScriptsForTools.
* Added CIPD related methods from https://skia-review.googlesource.com/c/buildbot/+/215085
* Added new interface in go/cipd to create mocks from.
* Added a config file for a potential new roller- fuchsia-mac-sdk-cipd-flutter-engine.json


Bug: skia:9078
Change-Id: Ic82b6e4f81d46952af9786b6ddbd63f310d30526
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/214688
Commit-Queue: Ravi Mistry <rmistry@google.com>
Reviewed-by: Eric Boren <borenet@google.com>
diff --git a/autoroll/config/fuchsia-mac-sdk-cipd-flutter-engine.json b/autoroll/config/fuchsia-mac-sdk-cipd-flutter-engine.json
new file mode 100644
index 0000000..32b71d1
--- /dev/null
+++ b/autoroll/config/fuchsia-mac-sdk-cipd-flutter-engine.json
@@ -0,0 +1,66 @@
+// See https://skia.googlesource.com/buildbot.git/+/master/autoroll/go/roller/config.go#130
+// for documentation of the autoroller config.
+{
+  "childName": "Skia",
+  "contacts": [
+    "rmistry@google.com"
+  ],
+  "isInternal": false,
+  "parentName": "Flutter",
+  "parentWaterfall": "https://build.chromium.org/p/client.flutter/console",
+  "rollerName": "cipd-flutter-engine-autoroll",
+  "serviceAccount": "flutter-engine-autoroll@skia-public.iam.gserviceaccount.com",
+  "sheriff": [
+    "rmistry@google.com"
+  ],
+  "github": {
+    "repoOwner": "flutter",
+    "repoName": "engine",
+    "checksNum": 4,
+    "checksWaitFor": [
+      "luci-engine"
+    ]
+  },
+  "githubCipdDEPSRepoManager": {
+    "childBranch": "master",
+    "childPath": "src/fuchsia/sdk/mac",
+    "parentBranch": "master",
+    "preUploadSteps": [
+      "FlutterLicenseScriptsForTools"
+    ],
+    "parentRepo": "git@github.com:flutter/engine.git",
+    "gclientSpec": "solutions=[{\"name\":\"src/flutter\",\"url\":\"git@github.com:rmistry/engine.git\",\"deps_file\":\"DEPS\",\"managed\":False,\"custom_deps\":{},\"custom_vars\":{\"host_os\":\"mac\"},\"safesync_url\":\"\"}]",
+    "githubParentPath": "src/flutter",
+    "cipdAssetName": "fuchsia/sdk/core/mac-amd64",
+    "cipdAssetTag": "latest"
+  },
+  "kubernetes": {
+    "cpu": "1",
+    "memory": "8Gi",
+    "disk": "50Gi",
+    "readinessInitialDelaySeconds": "600",
+    "readinessPeriodSeconds": "60",
+    "readinessFailureThreshold": "10",
+    "secrets": [
+      {
+        "name": "flutter-engine-github-token",
+        "mountPath": "/var/secrets/github-token"
+      },
+      {
+        "name": "flutter-engine-ssh-key",
+        "mountPath": "/var/secrets/ssh-key"
+      }
+    ]
+  },
+  "maxRollFrequency": "3h",
+  "notifiers": [
+    {
+      "filter": "warning",
+      "email": {
+        "emails": [
+          "$SHERIFF"
+        ]
+      }
+    }
+  ]
+}
diff --git a/autoroll/go/repo_manager/github_cipd_deps_repo_manager.go b/autoroll/go/repo_manager/github_cipd_deps_repo_manager.go
new file mode 100644
index 0000000..08ae168
--- /dev/null
+++ b/autoroll/go/repo_manager/github_cipd_deps_repo_manager.go
@@ -0,0 +1,358 @@
+package repo_manager
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"os"
+	"path"
+	"strings"
+	"time"
+
+	"go.skia.org/infra/autoroll/go/codereview"
+	"go.skia.org/infra/autoroll/go/revision"
+	"go.skia.org/infra/autoroll/go/strategy"
+	"go.skia.org/infra/go/cipd"
+	"go.skia.org/infra/go/exec"
+	"go.skia.org/infra/go/git"
+	"go.skia.org/infra/go/github"
+	"go.skia.org/infra/go/sklog"
+	"go.skia.org/infra/go/util"
+)
+
+const (
+	cipdPackageUrlTmpl = "%s/p/%s/+/%s"
+
+	cipdGithubTitleTmpl = "Roll %s from %s to %s"
+	cipdCommitMsgTmpl   = cipdGithubTitleTmpl + `
+
+` + COMMIT_MSG_FOOTER_TMPL
+)
+
+var (
+	// Use this function to instantiate a NewGithubCipdDEPSRepoManager. This is able to be
+	// overridden for testing.
+	NewGithubCipdDEPSRepoManager func(context.Context, *GithubCipdDEPSRepoManagerConfig, string, string, *github.GitHub, string, string, *http.Client, codereview.CodeReview, bool) (RepoManager, error) = newGithubCipdDEPSRepoManager
+)
+
+// GithubCipdDEPSRepoManagerConfig provides configuration for the Github RepoManager.
+type GithubCipdDEPSRepoManagerConfig struct {
+	GithubDEPSRepoManagerConfig
+	CipdAssetName string `json:"cipdAssetName"`
+	CipdAssetTag  string `json:"cipdAssetTag"`
+}
+
+// Validate the config.
+func (c *GithubCipdDEPSRepoManagerConfig) Validate() error {
+	if c.CipdAssetName == "" {
+		return fmt.Errorf("CipdAssetName is required.")
+	}
+	if c.CipdAssetTag == "" {
+		return fmt.Errorf("CipdAssetTag is required.")
+	}
+	return c.GithubDEPSRepoManagerConfig.Validate()
+}
+
+// githubCipdDEPSRepoManager is a struct used by the autoroller for managing checkouts.
+type githubCipdDEPSRepoManager struct {
+	*githubDEPSRepoManager
+	rollBranchName string
+	cipdAssetName  string
+	cipdAssetTag   string
+	CipdClient     cipd.CIPDClient
+}
+
+// newGithubCipdDEPSRepoManager returns a RepoManager instance which operates in the given
+// working directory and updates at the given frequency.
+func newGithubCipdDEPSRepoManager(ctx context.Context, c *GithubCipdDEPSRepoManagerConfig, workdir, rollerName string, githubClient *github.GitHub, recipeCfgFile, serverURL string, httpClient *http.Client, cr codereview.CodeReview, local bool) (RepoManager, error) {
+	if err := c.Validate(); err != nil {
+		return nil, err
+	}
+	wd := path.Join(workdir, strings.TrimSuffix(path.Base(c.DepotToolsRepoManagerConfig.ParentRepo), ".git"))
+	drm, err := newDepotToolsRepoManager(ctx, c.DepotToolsRepoManagerConfig, wd, recipeCfgFile, serverURL, nil, httpClient, cr, local)
+	if err != nil {
+		return nil, err
+	}
+	dr := &depsRepoManager{
+		depotToolsRepoManager: drm,
+	}
+	if c.GithubParentPath != "" {
+		dr.parentDir = path.Join(wd, c.GithubParentPath)
+	}
+	gr := &githubDEPSRepoManager{
+		depsRepoManager: dr,
+		githubClient:    githubClient,
+	}
+	sklog.Infof("Roller name is: %s\n", rollerName)
+	cipdClient, err := cipd.NewClient(httpClient, path.Join(workdir, "cipd"))
+	if err != nil {
+		return nil, err
+	}
+	gcr := &githubCipdDEPSRepoManager{
+		githubDEPSRepoManager: gr,
+		rollBranchName:        rollerName,
+		cipdAssetName:         c.CipdAssetName,
+		cipdAssetTag:          c.CipdAssetTag,
+		CipdClient:            cipdClient,
+	}
+
+	return gcr, nil
+}
+
+// See documentation for RepoManager interface.
+func (rm *githubCipdDEPSRepoManager) Update(ctx context.Context) error {
+	// Sync the projects.
+	rm.repoMtx.Lock()
+	defer rm.repoMtx.Unlock()
+
+	sklog.Info("Updating github repository")
+
+	// If parentDir does not exist yet then create the directory structure and
+	// populate it.
+	if _, err := os.Stat(rm.parentDir); err != nil {
+		if os.IsNotExist(err) {
+			if err := rm.createAndSyncParent(ctx); err != nil {
+				return fmt.Errorf("Could not create and sync %s: %s", rm.parentDir, err)
+			}
+			// Run gclient hooks to bring in any required binaries.
+			if _, err := exec.RunCommand(ctx, &exec.Command{
+				Dir:  rm.parentDir,
+				Env:  rm.depotToolsEnv,
+				Name: rm.gclient,
+				Args: []string{"runhooks"},
+			}); err != nil {
+				return fmt.Errorf("Error when running gclient runhooks on %s: %s", rm.parentDir, err)
+			}
+		} else {
+			return fmt.Errorf("Error when running os.Stat on %s: %s", rm.parentDir, err)
+		}
+	}
+
+	// Check to see whether there is an upstream yet.
+	remoteOutput, err := git.GitDir(rm.parentDir).Git(ctx, "remote", "show")
+	if err != nil {
+		return err
+	}
+	remoteFound := false
+	remoteLines := strings.Split(remoteOutput, "\n")
+	for _, remoteLine := range remoteLines {
+		if remoteLine == GITHUB_UPSTREAM_REMOTE_NAME {
+			remoteFound = true
+			break
+		}
+	}
+	if !remoteFound {
+		if _, err := git.GitDir(rm.parentDir).Git(ctx, "remote", "add", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentRepo); err != nil {
+			return err
+		}
+	}
+	// Pull from upstream.
+	if _, err := git.GitDir(rm.parentDir).Git(ctx, "pull", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentBranch); err != nil {
+		return err
+	}
+
+	// Get the last roll revision.
+	lastRollRev, err := rm.getLastRollRev(ctx)
+	if err != nil {
+		return err
+	}
+
+	// Find the not-rolled child repo commits.
+	notRolledRevs, err := getNotRolledRevs(ctx, rm.CipdClient, rm.lastRollRev, rm.cipdAssetName, rm.cipdAssetTag)
+	if err != nil {
+		return err
+	}
+
+	// Get the next roll revision.
+	nextRollRev, err := rm.getNextRollRev(ctx, notRolledRevs, lastRollRev)
+	if err != nil {
+		return err
+	}
+
+	rm.infoMtx.Lock()
+	defer rm.infoMtx.Unlock()
+	if rm.childRepoUrl == "" {
+		childRepo, err := exec.RunCwd(ctx, rm.parentDir, "git", "remote", "get-url", "origin")
+		if err != nil {
+			return err
+		}
+		rm.childRepoUrl = childRepo
+	}
+
+	rm.lastRollRev = lastRollRev
+	rm.nextRollRev = nextRollRev
+	rm.notRolledRevs = notRolledRevs
+
+	sklog.Infof("lastRollRev is: %s", rm.lastRollRev)
+	sklog.Infof("nextRollRev is: %s", nextRollRev)
+	sklog.Infof("notRolledRevs: %v", rm.notRolledRevs)
+	return nil
+}
+
+// getNotRolledRevs is a utility function that uses CIPD to find the not-yet-rolled versions of
+// the specified package.
+// Note: that this just finds all versions of the package between the last rolled version and the
+// version currently pointed to by cipdAssetTag; we can't know whether the ref we're tracking was
+// ever actually applied to any of the package instances in between.
+func getNotRolledRevs(ctx context.Context, cipdClient cipd.CIPDClient, lastRollRev, cipdAssetName, cipdAssetTag string) ([]*revision.Revision, error) {
+	head, err := cipdClient.ResolveVersion(ctx, cipdAssetName, cipdAssetTag)
+	if err != nil {
+		return nil, err
+	}
+	iter, err := cipdClient.ListInstances(ctx, cipdAssetName)
+	if err != nil {
+		return nil, err
+	}
+	notRolledRevs := []*revision.Revision{}
+	foundHead := false
+	for {
+		instances, err := iter.Next(ctx, 100)
+		if err != nil {
+			return nil, err
+		}
+		if len(instances) == 0 {
+			break
+		}
+		for _, instance := range instances {
+			id := instance.Pin.InstanceID
+			if id == head.InstanceID {
+				foundHead = true
+			}
+			if id == lastRollRev {
+				break
+			}
+			if foundHead {
+				notRolledRevs = append(notRolledRevs, &revision.Revision{
+					Id:          id,
+					Display:     instance.Pin.String(),
+					Description: instance.Pin.String(),
+					Timestamp:   time.Time(instance.RegisteredTs),
+					URL:         fmt.Sprintf(cipdPackageUrlTmpl, cipd.SERVICE_URL, cipdAssetName, id),
+				})
+			}
+		}
+	}
+	return notRolledRevs, nil
+}
+
+// See documentation for RepoManager interface.
+func (rm *githubCipdDEPSRepoManager) getLastRollRev(ctx context.Context) (string, error) {
+	output, err := exec.RunCwd(ctx, rm.parentDir, "python", rm.gclient, "getdep", "-r", fmt.Sprintf("%s:%s", rm.childPath, rm.cipdAssetName))
+	if err != nil {
+		return "", err
+	}
+	commit := strings.TrimSpace(output)
+	if commit == "" {
+		return "", fmt.Errorf("Got invalid output for `gclient getdep`: %s", output)
+	}
+	return commit, nil
+}
+
+// See documentation for RepoManager interface.
+func (rm *githubCipdDEPSRepoManager) CreateNewRoll(ctx context.Context, from, to string, emails []string, cqExtraTrybots string, dryRun bool) (int64, error) {
+	rm.repoMtx.Lock()
+	defer rm.repoMtx.Unlock()
+
+	sklog.Info("Creating a new Github Roll")
+
+	// Clean the checkout, get onto a fresh branch.
+	if err := rm.cleanParentWithRemoteAndBranch(ctx, GITHUB_UPSTREAM_REMOTE_NAME, rm.rollBranchName); err != nil {
+		return 0, err
+	}
+	if _, err := git.GitDir(rm.parentDir).Git(ctx, "checkout", fmt.Sprintf("%s/%s", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentBranch), "-b", rm.rollBranchName); err != nil {
+		return 0, err
+	}
+	// Defer cleanup.
+	defer func() {
+		util.LogErr(rm.cleanParentWithRemoteAndBranch(ctx, GITHUB_UPSTREAM_REMOTE_NAME, rm.rollBranchName))
+	}()
+
+	// Make sure the forked repo is at the same hash as the target repo before
+	// creating the pull request on the rm.rollBranchName.
+	if _, err := git.GitDir(rm.parentDir).Git(ctx, "push", "origin", rm.rollBranchName, "-f"); err != nil {
+		return 0, err
+	}
+
+	// Make sure the right name and email are set.
+	if !rm.local {
+		if _, err := git.GitDir(rm.parentDir).Git(ctx, "config", "user.name", rm.codereview.UserName()); err != nil {
+			return 0, err
+		}
+		if _, err := git.GitDir(rm.parentDir).Git(ctx, "config", "user.email", rm.codereview.UserEmail()); err != nil {
+			return 0, err
+		}
+	}
+
+	// Run "gclient setdep".
+	args := []string{"setdep", "-r", fmt.Sprintf("%s:%s@%s", rm.childPath, rm.cipdAssetName, to)}
+	if _, err := exec.RunCommand(ctx, &exec.Command{
+		Dir:  rm.parentDir,
+		Env:  rm.depotToolsEnv,
+		Name: rm.gclient,
+		Args: args,
+	}); err != nil {
+		return 0, err
+	}
+
+	// Make the checkout match the new DEPS.
+	sklog.Info("Running gclient sync on the checkout")
+	if _, err := exec.RunCommand(ctx, &exec.Command{
+		Dir:  rm.depsRepoManager.parentDir,
+		Env:  rm.depotToolsEnv,
+		Name: rm.gclient,
+		Args: []string{"sync", "-D", "--process-all-deps", "-f"},
+	}); err != nil {
+		return 0, fmt.Errorf("Error when running gclient sync to make checkout match the new DEPS: %s", err)
+	}
+
+	// Run the pre-upload steps.
+	for _, s := range rm.PreUploadSteps() {
+		if err := s(ctx, rm.depotToolsEnv, rm.httpClient, rm.parentDir); err != nil {
+			return 0, fmt.Errorf("Error when running pre-upload step: %s", err)
+		}
+	}
+
+	// Build the commitMsg.
+	commitMsg := fmt.Sprintf(cipdCommitMsgTmpl, rm.cipdAssetName, from, to, rm.serverURL)
+
+	// Commit.
+	if _, err := git.GitDir(rm.parentDir).Git(ctx, "commit", "-a", "-m", commitMsg); err != nil {
+		return 0, err
+	}
+
+	// Push to the forked repository.
+	if _, err := git.GitDir(rm.parentDir).Git(ctx, "push", "origin", rm.rollBranchName, "-f"); err != nil {
+		return 0, err
+	}
+
+	// Create a pull request.
+	title := fmt.Sprintf(cipdGithubTitleTmpl, rm.cipdAssetName, from[:5]+"...", to[:5]+"...")
+	headBranch := fmt.Sprintf("%s:%s", rm.codereview.UserName(), rm.rollBranchName)
+	pr, err := rm.githubClient.CreatePullRequest(title, rm.parentBranch, headBranch, commitMsg)
+	if err != nil {
+		return 0, err
+	}
+
+	// Add appropriate label to the pull request.
+	label := github.COMMIT_LABEL
+	if dryRun {
+		label = github.DRYRUN_LABEL
+	}
+	if err := rm.githubClient.AddLabel(pr.GetNumber(), label); err != nil {
+		return 0, err
+	}
+
+	return int64(pr.GetNumber()), nil
+}
+
+// See documentation for RepoManager interface.
+func (r *githubCipdDEPSRepoManager) DefaultStrategy() string {
+	return strategy.ROLL_STRATEGY_BATCH
+}
+
+// See documentation for RepoManager interface.
+func (r *githubCipdDEPSRepoManager) ValidStrategies() []string {
+	return []string{
+		strategy.ROLL_STRATEGY_BATCH,
+	}
+}
diff --git a/autoroll/go/repo_manager/github_cipd_deps_repo_manager_test.go b/autoroll/go/repo_manager/github_cipd_deps_repo_manager_test.go
new file mode 100644
index 0000000..df57329
--- /dev/null
+++ b/autoroll/go/repo_manager/github_cipd_deps_repo_manager_test.go
@@ -0,0 +1,255 @@
+package repo_manager
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"path/filepath"
+	"testing"
+
+	assert "github.com/stretchr/testify/require"
+	"go.chromium.org/luci/cipd/client/cipd"
+	"go.chromium.org/luci/cipd/common"
+	"go.skia.org/infra/autoroll/go/strategy"
+	"go.skia.org/infra/go/cipd/mocks"
+	"go.skia.org/infra/go/exec"
+	git_testutils "go.skia.org/infra/go/git/testutils"
+	"go.skia.org/infra/go/recipe_cfg"
+	"go.skia.org/infra/go/testutils"
+	"go.skia.org/infra/go/testutils/unittest"
+)
+
+const (
+	GITHUB_CIPD_DEPS_CHILD_PATH = "path/to/child"
+	GITHUB_CIPD_ASSET_NAME      = "test/cipd/name"
+	GITHUB_CIPD_ASSET_TAG       = "latest"
+
+	LAST_ROLLED  = "xyz12345"
+	NOT_ROLLED_1 = "abc12345"
+	NOT_ROLLED_2 = "def12345"
+)
+
+func githubCipdDEPSRmCfg() *GithubCipdDEPSRepoManagerConfig {
+	return &GithubCipdDEPSRepoManagerConfig{
+		GithubDEPSRepoManagerConfig: GithubDEPSRepoManagerConfig{
+			DepotToolsRepoManagerConfig: DepotToolsRepoManagerConfig{
+				CommonRepoManagerConfig: CommonRepoManagerConfig{
+					ChildBranch:  "master",
+					ChildPath:    GITHUB_CIPD_DEPS_CHILD_PATH,
+					ParentBranch: "master",
+				},
+			},
+		},
+		CipdAssetName: GITHUB_CIPD_ASSET_NAME,
+		CipdAssetTag:  "latest",
+	}
+}
+
+func setupGithubCipdDEPS(t *testing.T) (context.Context, string, *git_testutils.GitBuilder, *exec.CommandCollector, func()) {
+	wd, err := ioutil.TempDir("", "")
+	assert.NoError(t, err)
+
+	// Create child and parent repos.
+	childPath := filepath.Join(wd, "github_repos", "earth")
+	assert.NoError(t, os.MkdirAll(childPath, 0755))
+
+	parent := git_testutils.GitInit(t, context.Background())
+	parent.Add(context.Background(), "DEPS", fmt.Sprintf(`
+deps = {
+  "%s": {
+    "packages": [
+	  {
+	    "package": "%s",
+	    "version": "%s"
+	  }
+	],
+  },
+}`, GITHUB_CIPD_DEPS_CHILD_PATH, GITHUB_CIPD_ASSET_NAME, LAST_ROLLED))
+	parent.Commit(context.Background())
+
+	mockRun := &exec.CommandCollector{}
+	mockRun.SetDelegateRun(func(cmd *exec.Command) error {
+		if cmd.Name == "git" {
+			if cmd.Args[0] == "clone" || cmd.Args[0] == "fetch" {
+				return nil
+			}
+			if cmd.Args[0] == "checkout" && cmd.Args[1] == "remote/master" {
+				// Pretend origin is the remote branch for testing ease.
+				cmd.Args[1] = "origin/master"
+			}
+		}
+		return exec.DefaultRun(cmd)
+	})
+	ctx := exec.NewContext(context.Background(), mockRun.Run)
+
+	cleanup := func() {
+		testutils.RemoveAll(t, wd)
+		parent.Cleanup()
+	}
+
+	return ctx, wd, parent, mockRun, cleanup
+}
+
+type instanceEnumeratorImpl struct {
+	done bool
+}
+
+func (e *instanceEnumeratorImpl) Next(ctx context.Context, limit int) ([]cipd.InstanceInfo, error) {
+	if e.done {
+		return nil, nil
+	}
+	instances := []cipd.InstanceInfo{}
+	instance0 := cipd.InstanceInfo{
+		Pin: common.Pin{
+			PackageName: GITHUB_CIPD_ASSET_NAME,
+			InstanceID:  LAST_ROLLED,
+		},
+		RegisteredBy: "aquaman@ocean.com",
+	}
+	instance1 := cipd.InstanceInfo{
+		Pin: common.Pin{
+			PackageName: GITHUB_CIPD_ASSET_NAME,
+			InstanceID:  NOT_ROLLED_1,
+		},
+		RegisteredBy: "superman@krypton.com",
+	}
+	instance2 := cipd.InstanceInfo{
+		Pin: common.Pin{
+			PackageName: GITHUB_CIPD_ASSET_NAME,
+			InstanceID:  NOT_ROLLED_2,
+		},
+		RegisteredBy: "batman@gotham.com",
+	}
+	instances = append(instances, instance0, instance1, instance2)
+	e.done = true
+	return instances, nil
+}
+
+func getCipdMock(ctx context.Context) *mocks.CIPDClient {
+	cipdClient := &mocks.CIPDClient{}
+	head := common.Pin{
+		PackageName: "test/cipd/name",
+		InstanceID:  NOT_ROLLED_1,
+	}
+	cipdClient.On("ResolveVersion", ctx, GITHUB_CIPD_ASSET_NAME, GITHUB_CIPD_ASSET_TAG).Return(head, nil).Once()
+	cipdClient.On("ListInstances", ctx, GITHUB_CIPD_ASSET_NAME).Return(&instanceEnumeratorImpl{}, nil).Once()
+	return cipdClient
+}
+
+// TestGithubRepoManager tests all aspects of the GithubRepoManager except for CreateNewRoll.
+func TestGithubCipdDEPSRepoManager(t *testing.T) {
+	unittest.LargeTest(t)
+
+	ctx, wd, parent, _, cleanup := setupGithubCipdDEPS(t)
+	defer cleanup()
+	recipesCfg := filepath.Join(testutils.GetRepoRoot(t), recipe_cfg.RECIPE_CFG_PATH)
+
+	g, _ := setupFakeGithub(t, nil)
+	cfg := githubCipdDEPSRmCfg()
+	cfg.ParentRepo = parent.RepoUrl()
+	rm, err := NewGithubCipdDEPSRepoManager(ctx, cfg, wd, "test_roller_name", g, recipesCfg, "fake.server.com", nil, githubCR(t, g), false)
+	assert.NoError(t, err)
+	rm.(*githubCipdDEPSRepoManager).CipdClient = getCipdMock(ctx)
+	assert.NoError(t, SetStrategy(ctx, rm, strategy.ROLL_STRATEGY_BATCH))
+	assert.NoError(t, rm.Update(ctx))
+
+	// Assert last roll, next roll and not rolled yet.
+	assert.Equal(t, LAST_ROLLED, rm.LastRollRev())
+	assert.Equal(t, NOT_ROLLED_1, rm.NextRollRev())
+	assert.Equal(t, 2, len(rm.NotRolledRevisions()))
+	assert.Equal(t, NOT_ROLLED_1, rm.NotRolledRevisions()[0].Id)
+	assert.Equal(t, fmt.Sprintf("%s:%s", GITHUB_CIPD_ASSET_NAME, NOT_ROLLED_1), rm.NotRolledRevisions()[0].Display)
+	assert.Equal(t, NOT_ROLLED_2, rm.NotRolledRevisions()[1].Id)
+	assert.Equal(t, fmt.Sprintf("%s:%s", GITHUB_CIPD_ASSET_NAME, NOT_ROLLED_2), rm.NotRolledRevisions()[1].Display)
+}
+
+func TestCreateNewGithubCipdDEPSRoll(t *testing.T) {
+	unittest.LargeTest(t)
+
+	ctx, wd, parent, _, cleanup := setupGithubCipdDEPS(t)
+	defer cleanup()
+	recipesCfg := filepath.Join(testutils.GetRepoRoot(t), recipe_cfg.RECIPE_CFG_PATH)
+
+	g, urlMock := setupFakeGithub(t, nil)
+	cfg := githubCipdDEPSRmCfg()
+	cfg.ParentRepo = parent.RepoUrl()
+	rm, err := NewGithubCipdDEPSRepoManager(ctx, cfg, wd, "test_roller_name", g, recipesCfg, "fake.server.com", nil, githubCR(t, g), false)
+	assert.NoError(t, err)
+	rm.(*githubCipdDEPSRepoManager).CipdClient = getCipdMock(ctx)
+	assert.NoError(t, SetStrategy(ctx, rm, strategy.ROLL_STRATEGY_BATCH))
+	assert.NoError(t, rm.Update(ctx))
+
+	// Create a roll.
+	mockGithubRequests(t, urlMock, rm.LastRollRev(), rm.NextRollRev(), len(rm.NotRolledRevisions()))
+	issue, err := rm.CreateNewRoll(ctx, rm.LastRollRev(), rm.NextRollRev(), githubEmails, cqExtraTrybots, false)
+	assert.NoError(t, err)
+	assert.Equal(t, issueNum, issue)
+}
+
+// Verify that we ran the PreUploadSteps.
+func TestRanPreUploadStepsGithubCipdDEPS(t *testing.T) {
+	unittest.LargeTest(t)
+
+	ctx, wd, parent, _, cleanup := setupGithubCipdDEPS(t)
+	defer cleanup()
+	recipesCfg := filepath.Join(testutils.GetRepoRoot(t), recipe_cfg.RECIPE_CFG_PATH)
+
+	g, urlMock := setupFakeGithub(t, nil)
+	cfg := githubCipdDEPSRmCfg()
+	cfg.ParentRepo = parent.RepoUrl()
+	rm, err := NewGithubCipdDEPSRepoManager(ctx, cfg, wd, "test_roller_name", g, recipesCfg, "fake.server.com", nil, githubCR(t, g), false)
+	assert.NoError(t, err)
+	rm.(*githubCipdDEPSRepoManager).CipdClient = getCipdMock(ctx)
+	assert.NoError(t, SetStrategy(ctx, rm, strategy.ROLL_STRATEGY_BATCH))
+	assert.NoError(t, rm.Update(ctx))
+
+	ran := false
+	rm.(*githubCipdDEPSRepoManager).preUploadSteps = []PreUploadStep{
+		func(context.Context, []string, *http.Client, string) error {
+			ran = true
+			return nil
+		},
+	}
+
+	// Create a roll, assert that we ran the PreUploadSteps.
+	mockGithubRequests(t, urlMock, rm.LastRollRev(), rm.NextRollRev(), len(rm.NotRolledRevisions()))
+	_, createErr := rm.CreateNewRoll(ctx, rm.LastRollRev(), rm.NextRollRev(), githubEmails, cqExtraTrybots, false)
+	assert.NoError(t, createErr)
+	assert.True(t, ran)
+}
+
+// Verify that we fail when a PreUploadStep fails.
+func TestErrorPreUploadStepsGithubCipdDEPS(t *testing.T) {
+	unittest.LargeTest(t)
+
+	ctx, wd, parent, _, cleanup := setupGithubCipdDEPS(t)
+	defer cleanup()
+	recipesCfg := filepath.Join(testutils.GetRepoRoot(t), recipe_cfg.RECIPE_CFG_PATH)
+
+	g, urlMock := setupFakeGithub(t, nil)
+	cfg := githubCipdDEPSRmCfg()
+	cfg.ParentRepo = parent.RepoUrl()
+	rm, err := NewGithubCipdDEPSRepoManager(ctx, cfg, wd, "test_roller_name", g, recipesCfg, "fake.server.com", nil, githubCR(t, g), false)
+	assert.NoError(t, err)
+	rm.(*githubCipdDEPSRepoManager).CipdClient = getCipdMock(ctx)
+	assert.NoError(t, SetStrategy(ctx, rm, strategy.ROLL_STRATEGY_BATCH))
+	assert.NoError(t, rm.Update(ctx))
+
+	ran := false
+	expectedErr := errors.New("Expected error")
+	rm.(*githubCipdDEPSRepoManager).preUploadSteps = []PreUploadStep{
+		func(context.Context, []string, *http.Client, string) error {
+			ran = true
+			return expectedErr
+		},
+	}
+
+	// Create a roll, assert that we ran the PreUploadSteps.
+	mockGithubRequests(t, urlMock, rm.LastRollRev(), rm.NextRollRev(), len(rm.NotRolledRevisions()))
+	_, createErr := rm.CreateNewRoll(ctx, rm.LastRollRev(), rm.NextRollRev(), githubEmails, cqExtraTrybots, false)
+	assert.Error(t, expectedErr, createErr)
+	assert.True(t, ran)
+}
diff --git a/autoroll/go/repo_manager/github_deps_repo_manager.go b/autoroll/go/repo_manager/github_deps_repo_manager.go
index 3b02f55..d901839 100644
--- a/autoroll/go/repo_manager/github_deps_repo_manager.go
+++ b/autoroll/go/repo_manager/github_deps_repo_manager.go
@@ -124,7 +124,7 @@
 	}
 	// gclient sync to get latest version of child repo to find the next roll
 	// rev from.
-	if err := rm.createAndSyncParentWithRemote(ctx, GITHUB_UPSTREAM_REMOTE_NAME); err != nil {
+	if err := rm.createAndSyncParentWithRemoteAndBranch(ctx, GITHUB_UPSTREAM_REMOTE_NAME, ROLL_BRANCH); err != nil {
 		return fmt.Errorf("Could not create and sync parent repo: %s", err)
 	}
 
@@ -174,7 +174,7 @@
 	sklog.Info("Creating a new Github Roll")
 
 	// Clean the checkout, get onto a fresh branch.
-	if err := rm.cleanParentWithRemote(ctx, GITHUB_UPSTREAM_REMOTE_NAME); err != nil {
+	if err := rm.cleanParentWithRemoteAndBranch(ctx, GITHUB_UPSTREAM_REMOTE_NAME, ROLL_BRANCH); err != nil {
 		return 0, err
 	}
 	if _, err := git.GitDir(rm.parentDir).Git(ctx, "checkout", fmt.Sprintf("%s/%s", GITHUB_UPSTREAM_REMOTE_NAME, rm.parentBranch), "-b", ROLL_BRANCH); err != nil {
@@ -182,7 +182,7 @@
 	}
 	// Defer cleanup.
 	defer func() {
-		util.LogErr(rm.cleanParentWithRemote(ctx, GITHUB_UPSTREAM_REMOTE_NAME))
+		util.LogErr(rm.cleanParentWithRemoteAndBranch(ctx, GITHUB_UPSTREAM_REMOTE_NAME, ROLL_BRANCH))
 	}()
 
 	// Make sure the forked repo is at the same hash as the target repo before
diff --git a/autoroll/go/repo_manager/github_repo_manager_test.go b/autoroll/go/repo_manager/github_repo_manager_test.go
index fadebad..2eea5d3 100644
--- a/autoroll/go/repo_manager/github_repo_manager_test.go
+++ b/autoroll/go/repo_manager/github_repo_manager_test.go
@@ -115,8 +115,10 @@
 	assert.NoError(t, err)
 	urlMock.MockOnce(githubApiUrl+"/user", mockhttpclient.MockGetDialogue(serializedUser))
 
-	// Mock getRawFile.
-	urlMock.MockOnce("https://raw.githubusercontent.com/superman/krypton/master/dummy-file.txt", mockhttpclient.MockGetDialogue([]byte(childCommits[0])))
+	if childCommits != nil && len(childCommits) > 0 {
+		// Mock getRawFile.
+		urlMock.MockOnce("https://raw.githubusercontent.com/superman/krypton/master/dummy-file.txt", mockhttpclient.MockGetDialogue([]byte(childCommits[0])))
+	}
 
 	// Mock /issues endpoint for get and patch requests.
 	serializedIssue, err := json.Marshal(&github_api.Issue{
diff --git a/autoroll/go/repo_manager/pre_upload_steps.go b/autoroll/go/repo_manager/pre_upload_steps.go
index 6a50d73..545fb8b 100644
--- a/autoroll/go/repo_manager/pre_upload_steps.go
+++ b/autoroll/go/repo_manager/pre_upload_steps.go
@@ -34,9 +34,10 @@
 // Return the PreUploadStep with the given name.
 func GetPreUploadStep(s string) (PreUploadStep, error) {
 	rv, ok := map[string]PreUploadStep{
-		"GoGenerateCipd":        GoGenerateCipd,
-		"TrainInfra":            TrainInfra,
-		"FlutterLicenseScripts": FlutterLicenseScripts,
+		"GoGenerateCipd":                GoGenerateCipd,
+		"TrainInfra":                    TrainInfra,
+		"FlutterLicenseScripts":         FlutterLicenseScripts,
+		"FlutterLicenseScriptsForTools": FlutterLicenseScriptsForTools,
 	}[s]
 	if !ok {
 		return nil, fmt.Errorf("No such pre-upload step: %s", s)
@@ -61,7 +62,7 @@
 func TrainInfra(ctx context.Context, env []string, client *http.Client, parentRepoDir string) error {
 	// TODO(borenet): Should we plumb through --local and --workdir?
 	sklog.Info("Installing Go...")
-	_, goEnv, err := go_install.EnsureGo(client, cipdRoot)
+	_, goEnv, err := go_install.EnsureGo(ctx, client, cipdRoot)
 	if err != nil {
 		return err
 	}
@@ -102,12 +103,32 @@
 // https://bugs.chromium.org/p/skia/issues/detail?id=7730#c6 and in
 // https://github.com/flutter/engine/blob/master/tools/licenses/README.md
 func FlutterLicenseScripts(ctx context.Context, _ []string, _ *http.Client, parentRepoDir string) error {
-	sklog.Info("Running flutter license scripts.")
 	licenseScriptFailure := int64(1)
 	defer func() {
 		metrics2.GetInt64Metric("flutter_license_script_failure", nil).Update(licenseScriptFailure)
 	}()
+	if err := flutterLicenseScripts(ctx, parentRepoDir, "licenses_skia"); err != nil {
+		return err
+	}
+	licenseScriptFailure = 0
+	return nil
+}
 
+// Run the flutter license scripts for tools.
+func FlutterLicenseScriptsForTools(ctx context.Context, _ []string, _ *http.Client, parentRepoDir string) error {
+	licenseScriptFailure := int64(1)
+	defer func() {
+		metrics2.GetInt64Metric("flutter_license_script_failure", nil).Update(licenseScriptFailure)
+	}()
+	if err := flutterLicenseScripts(ctx, parentRepoDir, "tools_signature"); err != nil {
+		return err
+	}
+	licenseScriptFailure = 0
+	return nil
+}
+
+func flutterLicenseScripts(ctx context.Context, parentRepoDir, licenseFileName string) error {
+	sklog.Info("Running flutter license scripts.")
 	binariesPath := filepath.Join(parentRepoDir, "..", "third_party", "dart", "tools", "sdks", "dart-sdk", "bin")
 
 	// Step1: Run pub get.
@@ -138,21 +159,20 @@
 		return fmt.Errorf("Error when running dart license script: %s", err)
 	}
 
-	licensesThirdPartyFileName := "licenses_skia"
-	// Step4: Check to see if licenses_third_party was created in the out dir.
-	//        It will be created if the third_party hash changes.
-	if _, err := os.Stat(filepath.Join(licensesOutDir, licensesThirdPartyFileName)); err == nil {
-		sklog.Infof("Found %s", licensesThirdPartyFileName)
+	// Step4: Check to see if the target license file was created in the out dir.
+	//        It will be created if the hash changes.
+	if _, err := os.Stat(filepath.Join(licensesOutDir, licenseFileName)); err == nil {
+		sklog.Infof("Found %s", licenseFileName)
 
 		// Step5: Copy from out dir to goldens dir. This is required for updating
 		//        the release file in sky_engine/LICENSE.
-		if _, err := exec.RunCwd(ctx, licenseToolsDir, "cp", filepath.Join(licensesOutDir, licensesThirdPartyFileName), filepath.Join(licensesGoldenDir, licensesThirdPartyFileName)); err != nil {
-			return fmt.Errorf("Error when copying licenses_third_party from out to golden dir: %s", err)
+		if _, err := exec.RunCwd(ctx, licenseToolsDir, "cp", filepath.Join(licensesOutDir, licenseFileName), filepath.Join(licensesGoldenDir, licenseFileName)); err != nil {
+			return fmt.Errorf("Error when copying %s from out to golden dir: %s", licenseFileName, err)
 		}
-		// Step6: Capture diff of licenses_golden/licenses_third_party.
-		licensesDiffOutput, err := git.GitDir(licenseToolsDir).Git(ctx, "diff", "--no-ext-diff", filepath.Join(licensesGoldenDir, licensesThirdPartyFileName))
+		// Step6: Capture diff of licenses_golden/${licenseFileName}.
+		licensesDiffOutput, err := git.GitDir(licenseToolsDir).Git(ctx, "diff", "--no-ext-diff", filepath.Join(licensesGoldenDir, licenseFileName))
 		if err != nil {
-			return fmt.Errorf("Error when seeing diff of golden licenses_third_party: %s", err)
+			return fmt.Errorf("Error when seeing diff of golden %s: %s", licenseFileName, err)
 		}
 		sklog.Infof("The licenses diff output is:\n%s", licensesDiffOutput)
 
@@ -177,7 +197,6 @@
 	}
 
 	sklog.Info("Done running flutter license scripts.")
-	licenseScriptFailure = 0
 	return nil
 }
 
@@ -185,7 +204,7 @@
 func GoGenerateCipd(ctx context.Context, _ []string, client *http.Client, parentRepoDir string) error {
 	// TODO(borenet): Should we plumb through --local and --workdir?
 	sklog.Info("Installing Go...")
-	goExc, goEnv, err := go_install.EnsureGo(client, cipdRoot)
+	goExc, goEnv, err := go_install.EnsureGo(ctx, client, cipdRoot)
 	if err != nil {
 		return err
 	}
@@ -193,7 +212,7 @@
 	// Also install the protoc asset. Use a different CIPD root dir to
 	// prevent conflicts with the Go packages.
 	protocRoot := path.Join(os.TempDir(), "cipd_protoc")
-	if err := cipd.Ensure(client, protocRoot, cipd.PkgProtoc); err != nil {
+	if err := cipd.Ensure(ctx, client, protocRoot, cipd.PkgProtoc); err != nil {
 		return err
 	}
 
diff --git a/autoroll/go/repo_manager/repo_manager.go b/autoroll/go/repo_manager/repo_manager.go
index 258c83b..402f5d6 100644
--- a/autoroll/go/repo_manager/repo_manager.go
+++ b/autoroll/go/repo_manager/repo_manager.go
@@ -394,10 +394,10 @@
 
 // cleanParent forces the parent checkout into a clean state.
 func (r *depotToolsRepoManager) cleanParent(ctx context.Context) error {
-	return r.cleanParentWithRemote(ctx, "origin")
+	return r.cleanParentWithRemoteAndBranch(ctx, "origin", ROLL_BRANCH)
 }
 
-func (r *depotToolsRepoManager) cleanParentWithRemote(ctx context.Context, remote string) error {
+func (r *depotToolsRepoManager) cleanParentWithRemoteAndBranch(ctx context.Context, remote, branch string) error {
 	if _, err := exec.RunCwd(ctx, r.parentDir, "git", "clean", "-d", "-f", "-f"); err != nil {
 		return err
 	}
@@ -405,7 +405,7 @@
 	if _, err := exec.RunCwd(ctx, r.parentDir, "git", "checkout", fmt.Sprintf("%s/%s", remote, r.parentBranch), "-f"); err != nil {
 		return err
 	}
-	_, _ = exec.RunCwd(ctx, r.parentDir, "git", "branch", "-D", ROLL_BRANCH)
+	_, _ = exec.RunCwd(ctx, r.parentDir, "git", "branch", "-D", branch)
 	if _, err := exec.RunCommand(ctx, &exec.Command{
 		Dir:  r.workdir,
 		Env:  r.depotToolsEnv,
@@ -418,10 +418,10 @@
 }
 
 func (r *depotToolsRepoManager) createAndSyncParent(ctx context.Context) error {
-	return r.createAndSyncParentWithRemote(ctx, "origin")
+	return r.createAndSyncParentWithRemoteAndBranch(ctx, "origin", ROLL_BRANCH)
 }
 
-func (r *depotToolsRepoManager) createAndSyncParentWithRemote(ctx context.Context, remote string) error {
+func (r *depotToolsRepoManager) createAndSyncParentWithRemoteAndBranch(ctx context.Context, remote, branch string) error {
 	// Create the working directory if needed.
 	if _, err := os.Stat(r.workdir); err != nil {
 		if err := os.MkdirAll(r.workdir, 0755); err != nil {
@@ -430,7 +430,7 @@
 	}
 
 	if _, err := os.Stat(path.Join(r.parentDir, ".git")); err == nil {
-		if err := r.cleanParentWithRemote(ctx, remote); err != nil {
+		if err := r.cleanParentWithRemoteAndBranch(ctx, remote, branch); err != nil {
 			return err
 		}
 		// Update the repo.
diff --git a/autoroll/go/roller/autoroller.go b/autoroll/go/roller/autoroller.go
index ba91766..afd1163 100644
--- a/autoroll/go/roller/autoroller.go
+++ b/autoroll/go/roller/autoroller.go
@@ -110,6 +110,8 @@
 		rm, err = repo_manager.NewFuchsiaSDKRepoManager(ctx, c.FuchsiaSDKRepoManager, workdir, g, serverURL, gitcookiesPath, nil, cr, local)
 	} else if c.GithubRepoManager != nil {
 		rm, err = repo_manager.NewGithubRepoManager(ctx, c.GithubRepoManager, workdir, githubClient, recipesCfgFile, serverURL, client, cr, local)
+	} else if c.GithubCipdDEPSRepoManager != nil {
+		rm, err = repo_manager.NewGithubCipdDEPSRepoManager(ctx, c.GithubCipdDEPSRepoManager, workdir, rollerName, githubClient, recipesCfgFile, serverURL, client, cr, local)
 	} else if c.GithubDEPSRepoManager != nil {
 		rm, err = repo_manager.NewGithubDEPSRepoManager(ctx, c.GithubDEPSRepoManager, workdir, githubClient, recipesCfgFile, serverURL, client, cr, local)
 	} else if c.ManifestRepoManager != nil {
diff --git a/autoroll/go/roller/config.go b/autoroll/go/roller/config.go
index 6d98cb7..2dcc480 100644
--- a/autoroll/go/roller/config.go
+++ b/autoroll/go/roller/config.go
@@ -175,6 +175,7 @@
 	FuchsiaSDKAndroidRepoManager *repo_manager.FuchsiaSDKAndroidRepoManagerConfig `json:"fuchsiaSDKAndroidRepoManager,omitempty"`
 	FuchsiaSDKRepoManager        *repo_manager.FuchsiaSDKRepoManagerConfig        `json:"fuchsiaSDKRepoManager,omitempty"`
 	GithubRepoManager            *repo_manager.GithubRepoManagerConfig            `json:"githubRepoManager,omitempty"`
+	GithubCipdDEPSRepoManager    *repo_manager.GithubCipdDEPSRepoManagerConfig    `json:"githubCipdDEPSRepoManager,omitempty"`
 	GithubDEPSRepoManager        *repo_manager.GithubDEPSRepoManagerConfig        `json:"githubDEPSRepoManager,omitempty"`
 	Google3RepoManager           *Google3FakeRepoManagerConfig                    `json:"google3,omitempty"`
 	ManifestRepoManager          *repo_manager.ManifestRepoManagerConfig          `json:"manifestRepoManager,omitempty"`
@@ -275,6 +276,9 @@
 	if c.GithubRepoManager != nil {
 		rm = append(rm, c.GithubRepoManager)
 	}
+	if c.GithubCipdDEPSRepoManager != nil {
+		rm = append(rm, c.GithubCipdDEPSRepoManager)
+	}
 	if c.GithubDEPSRepoManager != nil {
 		rm = append(rm, c.GithubDEPSRepoManager)
 	}
diff --git a/go/cipd/cipd.go b/go/cipd/cipd.go
index f53d8ed..569ce24 100644
--- a/go/cipd/cipd.go
+++ b/go/cipd/cipd.go
@@ -56,19 +56,48 @@
 }
 
 // Run "cipd ensure" to get the correct packages in the given location. Note
-// that any previously-installed packages in the given rootdir will be removed
+// that any previously-installed packages in the given rootDir will be removed
 // if not specified again.
-func Ensure(client *http.Client, rootdir string, packages ...*Package) error {
-	c, err := cipd.NewClient(cipd.ClientOptions{
-		ServiceURL:          SERVICE_URL,
-		Root:                rootdir,
-		AuthenticatedClient: client,
-	})
+func Ensure(ctx context.Context, c *http.Client, rootDir string, packages ...*Package) error {
+	cipdClient, err := NewClient(c, rootDir)
 	if err != nil {
 		return fmt.Errorf("Failed to create CIPD client: %s", err)
 	}
+	return cipdClient.Ensure(ctx, packages...)
+}
 
-	ctx := context.Background()
+// CIPDClient is the interface for interactions with the CIPD API.
+type CIPDClient interface {
+	cipd.Client
+
+	// Ensure runs "cipd ensure" to get the correct packages in the given location. Note
+	// that any previously-installed packages in the given rootDir will be removed
+	// if not specified again.
+	Ensure(ctx context.Context, packages ...*Package) error
+
+	// Describe is a convenience wrapper around cipd.Client.DescribeInstance.
+	Describe(ctx context.Context, pkg, instance string) (*cipd.InstanceDescription, error)
+}
+
+// Client is a struct used for interacting with the CIPD API.
+type Client struct {
+	cipd.Client
+}
+
+// NewClient returns a CIPD client.
+func NewClient(c *http.Client, rootDir string) (*Client, error) {
+	cipdClient, err := cipd.NewClient(cipd.ClientOptions{
+		ServiceURL:          SERVICE_URL,
+		Root:                rootDir,
+		AuthenticatedClient: c,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("Failed to create CIPD client: %s", err)
+	}
+	return &Client{cipdClient}, nil
+}
+
+func (c *Client) Ensure(ctx context.Context, packages ...*Package) error {
 	pkgs := common.PinSliceBySubdir{}
 	for _, pkg := range packages {
 		pin, err := c.ResolveVersion(ctx, pkg.Name, pkg.Version)
@@ -78,8 +107,20 @@
 		sklog.Infof("Installing version %s (from %s) of %s", pin.InstanceID, pkg.Version, pkg.Name)
 		pkgs[pkg.Dest] = common.PinSlice{pin}
 	}
-	if _, err := c.EnsurePackages(context.Background(), pkgs, cipd.CheckPresence, false); err != nil {
+	if _, err := c.EnsurePackages(ctx, pkgs, cipd.CheckPresence, false); err != nil {
 		return fmt.Errorf("Failed to ensure packages: %s", err)
 	}
 	return nil
 }
+
+func (c *Client) Describe(ctx context.Context, pkg, instance string) (*cipd.InstanceDescription, error) {
+	pin := common.Pin{
+		PackageName: pkg,
+		InstanceID:  instance,
+	}
+	opts := &cipd.DescribeInstanceOpts{
+		DescribeRefs: true,
+		DescribeTags: true,
+	}
+	return c.DescribeInstance(ctx, pin, opts)
+}
diff --git a/go/cipd/mocks/CIPDClient.go b/go/cipd/mocks/CIPDClient.go
new file mode 100644
index 0000000..2b05b65
--- /dev/null
+++ b/go/cipd/mocks/CIPDClient.go
@@ -0,0 +1,453 @@
+// Code generated by mockery v1.0.0. DO NOT EDIT.
+
+package mocks
+
+import cipd "go.skia.org/infra/go/cipd"
+import clientcipd "go.chromium.org/luci/cipd/client/cipd"
+import common "go.chromium.org/luci/cipd/common"
+import context "context"
+import deployer "go.chromium.org/luci/cipd/client/cipd/deployer"
+import io "io"
+import mock "github.com/stretchr/testify/mock"
+import pkg "go.chromium.org/luci/cipd/client/cipd/pkg"
+import time "time"
+
+// CIPDClient is an autogenerated mock type for the CIPDClient type
+type CIPDClient struct {
+	mock.Mock
+}
+
+// AttachTagsWhenReady provides a mock function with given fields: ctx, pin, tags
+func (_m *CIPDClient) AttachTagsWhenReady(ctx context.Context, pin common.Pin, tags []string) error {
+	ret := _m.Called(ctx, pin, tags)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, common.Pin, []string) error); ok {
+		r0 = rf(ctx, pin, tags)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// BeginBatch provides a mock function with given fields: ctx
+func (_m *CIPDClient) BeginBatch(ctx context.Context) {
+	_m.Called(ctx)
+}
+
+// CheckDeployment provides a mock function with given fields: ctx, paranoia
+func (_m *CIPDClient) CheckDeployment(ctx context.Context, paranoia deployer.ParanoidMode) (clientcipd.ActionMap, error) {
+	ret := _m.Called(ctx, paranoia)
+
+	var r0 clientcipd.ActionMap
+	if rf, ok := ret.Get(0).(func(context.Context, deployer.ParanoidMode) clientcipd.ActionMap); ok {
+		r0 = rf(ctx, paranoia)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(clientcipd.ActionMap)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, deployer.ParanoidMode) error); ok {
+		r1 = rf(ctx, paranoia)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Describe provides a mock function with given fields: ctx, _a1, instance
+func (_m *CIPDClient) Describe(ctx context.Context, _a1 string, instance string) (*clientcipd.InstanceDescription, error) {
+	ret := _m.Called(ctx, _a1, instance)
+
+	var r0 *clientcipd.InstanceDescription
+	if rf, ok := ret.Get(0).(func(context.Context, string, string) *clientcipd.InstanceDescription); ok {
+		r0 = rf(ctx, _a1, instance)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*clientcipd.InstanceDescription)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
+		r1 = rf(ctx, _a1, instance)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// DescribeClient provides a mock function with given fields: ctx, pin
+func (_m *CIPDClient) DescribeClient(ctx context.Context, pin common.Pin) (*clientcipd.ClientDescription, error) {
+	ret := _m.Called(ctx, pin)
+
+	var r0 *clientcipd.ClientDescription
+	if rf, ok := ret.Get(0).(func(context.Context, common.Pin) *clientcipd.ClientDescription); ok {
+		r0 = rf(ctx, pin)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*clientcipd.ClientDescription)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, common.Pin) error); ok {
+		r1 = rf(ctx, pin)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// DescribeInstance provides a mock function with given fields: ctx, pin, opts
+func (_m *CIPDClient) DescribeInstance(ctx context.Context, pin common.Pin, opts *clientcipd.DescribeInstanceOpts) (*clientcipd.InstanceDescription, error) {
+	ret := _m.Called(ctx, pin, opts)
+
+	var r0 *clientcipd.InstanceDescription
+	if rf, ok := ret.Get(0).(func(context.Context, common.Pin, *clientcipd.DescribeInstanceOpts) *clientcipd.InstanceDescription); ok {
+		r0 = rf(ctx, pin, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*clientcipd.InstanceDescription)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, common.Pin, *clientcipd.DescribeInstanceOpts) error); ok {
+		r1 = rf(ctx, pin, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// EndBatch provides a mock function with given fields: ctx
+func (_m *CIPDClient) EndBatch(ctx context.Context) {
+	_m.Called(ctx)
+}
+
+// Ensure provides a mock function with given fields: ctx, packages
+func (_m *CIPDClient) Ensure(ctx context.Context, packages ...*cipd.Package) error {
+	_va := make([]interface{}, len(packages))
+	for _i := range packages {
+		_va[_i] = packages[_i]
+	}
+	var _ca []interface{}
+	_ca = append(_ca, ctx)
+	_ca = append(_ca, _va...)
+	ret := _m.Called(_ca...)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, ...*cipd.Package) error); ok {
+		r0 = rf(ctx, packages...)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// EnsurePackages provides a mock function with given fields: ctx, pkgs, paranoia, dryRun
+func (_m *CIPDClient) EnsurePackages(ctx context.Context, pkgs common.PinSliceBySubdir, paranoia deployer.ParanoidMode, dryRun bool) (clientcipd.ActionMap, error) {
+	ret := _m.Called(ctx, pkgs, paranoia, dryRun)
+
+	var r0 clientcipd.ActionMap
+	if rf, ok := ret.Get(0).(func(context.Context, common.PinSliceBySubdir, deployer.ParanoidMode, bool) clientcipd.ActionMap); ok {
+		r0 = rf(ctx, pkgs, paranoia, dryRun)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(clientcipd.ActionMap)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, common.PinSliceBySubdir, deployer.ParanoidMode, bool) error); ok {
+		r1 = rf(ctx, pkgs, paranoia, dryRun)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// FetchACL provides a mock function with given fields: ctx, prefix
+func (_m *CIPDClient) FetchACL(ctx context.Context, prefix string) ([]clientcipd.PackageACL, error) {
+	ret := _m.Called(ctx, prefix)
+
+	var r0 []clientcipd.PackageACL
+	if rf, ok := ret.Get(0).(func(context.Context, string) []clientcipd.PackageACL); ok {
+		r0 = rf(ctx, prefix)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]clientcipd.PackageACL)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, prefix)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// FetchAndDeployInstance provides a mock function with given fields: ctx, subdir, pin
+func (_m *CIPDClient) FetchAndDeployInstance(ctx context.Context, subdir string, pin common.Pin) error {
+	ret := _m.Called(ctx, subdir, pin)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, common.Pin) error); ok {
+		r0 = rf(ctx, subdir, pin)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// FetchInstance provides a mock function with given fields: ctx, pin
+func (_m *CIPDClient) FetchInstance(ctx context.Context, pin common.Pin) (pkg.Source, error) {
+	ret := _m.Called(ctx, pin)
+
+	var r0 pkg.Source
+	if rf, ok := ret.Get(0).(func(context.Context, common.Pin) pkg.Source); ok {
+		r0 = rf(ctx, pin)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(pkg.Source)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, common.Pin) error); ok {
+		r1 = rf(ctx, pin)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// FetchInstanceTo provides a mock function with given fields: ctx, pin, output
+func (_m *CIPDClient) FetchInstanceTo(ctx context.Context, pin common.Pin, output io.WriteSeeker) error {
+	ret := _m.Called(ctx, pin, output)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, common.Pin, io.WriteSeeker) error); ok {
+		r0 = rf(ctx, pin, output)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// FetchPackageRefs provides a mock function with given fields: ctx, packageName
+func (_m *CIPDClient) FetchPackageRefs(ctx context.Context, packageName string) ([]clientcipd.RefInfo, error) {
+	ret := _m.Called(ctx, packageName)
+
+	var r0 []clientcipd.RefInfo
+	if rf, ok := ret.Get(0).(func(context.Context, string) []clientcipd.RefInfo); ok {
+		r0 = rf(ctx, packageName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]clientcipd.RefInfo)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, packageName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// FetchRoles provides a mock function with given fields: ctx, prefix
+func (_m *CIPDClient) FetchRoles(ctx context.Context, prefix string) ([]string, error) {
+	ret := _m.Called(ctx, prefix)
+
+	var r0 []string
+	if rf, ok := ret.Get(0).(func(context.Context, string) []string); ok {
+		r0 = rf(ctx, prefix)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]string)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, prefix)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// ListInstances provides a mock function with given fields: ctx, packageName
+func (_m *CIPDClient) ListInstances(ctx context.Context, packageName string) (clientcipd.InstanceEnumerator, error) {
+	ret := _m.Called(ctx, packageName)
+
+	var r0 clientcipd.InstanceEnumerator
+	if rf, ok := ret.Get(0).(func(context.Context, string) clientcipd.InstanceEnumerator); ok {
+		r0 = rf(ctx, packageName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(clientcipd.InstanceEnumerator)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, packageName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// ListPackages provides a mock function with given fields: ctx, prefix, recursive, includeHidden
+func (_m *CIPDClient) ListPackages(ctx context.Context, prefix string, recursive bool, includeHidden bool) ([]string, error) {
+	ret := _m.Called(ctx, prefix, recursive, includeHidden)
+
+	var r0 []string
+	if rf, ok := ret.Get(0).(func(context.Context, string, bool, bool) []string); ok {
+		r0 = rf(ctx, prefix, recursive, includeHidden)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]string)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, bool, bool) error); ok {
+		r1 = rf(ctx, prefix, recursive, includeHidden)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// ModifyACL provides a mock function with given fields: ctx, prefix, changes
+func (_m *CIPDClient) ModifyACL(ctx context.Context, prefix string, changes []clientcipd.PackageACLChange) error {
+	ret := _m.Called(ctx, prefix, changes)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, []clientcipd.PackageACLChange) error); ok {
+		r0 = rf(ctx, prefix, changes)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// RegisterInstance provides a mock function with given fields: ctx, pin, body, timeout
+func (_m *CIPDClient) RegisterInstance(ctx context.Context, pin common.Pin, body io.ReadSeeker, timeout time.Duration) error {
+	ret := _m.Called(ctx, pin, body, timeout)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, common.Pin, io.ReadSeeker, time.Duration) error); ok {
+		r0 = rf(ctx, pin, body, timeout)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// RepairDeployment provides a mock function with given fields: ctx, paranoia
+func (_m *CIPDClient) RepairDeployment(ctx context.Context, paranoia deployer.ParanoidMode) (clientcipd.ActionMap, error) {
+	ret := _m.Called(ctx, paranoia)
+
+	var r0 clientcipd.ActionMap
+	if rf, ok := ret.Get(0).(func(context.Context, deployer.ParanoidMode) clientcipd.ActionMap); ok {
+		r0 = rf(ctx, paranoia)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(clientcipd.ActionMap)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, deployer.ParanoidMode) error); ok {
+		r1 = rf(ctx, paranoia)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// ResolveVersion provides a mock function with given fields: ctx, packageName, version
+func (_m *CIPDClient) ResolveVersion(ctx context.Context, packageName string, version string) (common.Pin, error) {
+	ret := _m.Called(ctx, packageName, version)
+
+	var r0 common.Pin
+	if rf, ok := ret.Get(0).(func(context.Context, string, string) common.Pin); ok {
+		r0 = rf(ctx, packageName, version)
+	} else {
+		r0 = ret.Get(0).(common.Pin)
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
+		r1 = rf(ctx, packageName, version)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// SearchInstances provides a mock function with given fields: ctx, packageName, tags
+func (_m *CIPDClient) SearchInstances(ctx context.Context, packageName string, tags []string) (common.PinSlice, error) {
+	ret := _m.Called(ctx, packageName, tags)
+
+	var r0 common.PinSlice
+	if rf, ok := ret.Get(0).(func(context.Context, string, []string) common.PinSlice); ok {
+		r0 = rf(ctx, packageName, tags)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(common.PinSlice)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, []string) error); ok {
+		r1 = rf(ctx, packageName, tags)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// SetRefWhenReady provides a mock function with given fields: ctx, ref, pin
+func (_m *CIPDClient) SetRefWhenReady(ctx context.Context, ref string, pin common.Pin) error {
+	ret := _m.Called(ctx, ref, pin)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, common.Pin) error); ok {
+		r0 = rf(ctx, ref, pin)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
diff --git a/go/cipd/mocks/generate.go b/go/cipd/mocks/generate.go
new file mode 100644
index 0000000..59240cd
--- /dev/null
+++ b/go/cipd/mocks/generate.go
@@ -0,0 +1,3 @@
+package mocks
+
+//go:generate mockery -name CIPDClient -dir ../ -output .
diff --git a/go/go_install/go_install.go b/go/go_install/go_install.go
index 2589655..4fc3718 100644
--- a/go/go_install/go_install.go
+++ b/go/go_install/go_install.go
@@ -5,6 +5,7 @@
 */
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"path"
@@ -17,9 +18,9 @@
 // environment variables or any errors which occurred. If includeDeps is true,
 // also installs the go_deps CIPD package which contains all dependencies for
 // the go.skia.org/infra repository as of the last update of that package.
-func EnsureGo(client *http.Client, cipdRoot string) (string, map[string]string, error) {
+func EnsureGo(ctx context.Context, client *http.Client, cipdRoot string) (string, map[string]string, error) {
 	pkgs := []*cipd.Package{cipd.PkgGo}
-	if err := cipd.Ensure(client, cipdRoot, pkgs...); err != nil {
+	if err := cipd.Ensure(ctx, client, cipdRoot, pkgs...); err != nil {
 		return "", nil, fmt.Errorf("Failed to ensure Go CIPD package: %s", err)
 	}
 	goRoot := path.Join(cipdRoot, cipd.PkgGo.Dest, "go")