cmd/relui: add datastore support

This change replaces the file storage layer with Google Cloud Datastore.
It adds a fake implementation of the datastore client, supporting only
the features we use so far.

Slightly simplifies Workflow configuration.

Updates golang/go#40279

Change-Id: I55228f6540fbcdf5f803203ff7309232cebf6a20
Reviewed-on: https://go-review.googlesource.com/c/build/+/275237
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Trust: Alexander Rakoczy <alex@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/cmd/relui/internal/datastore/fake/client.go b/cmd/relui/internal/datastore/fake/client.go
new file mode 100644
index 0000000..c697c33
--- /dev/null
+++ b/cmd/relui/internal/datastore/fake/client.go
@@ -0,0 +1,144 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// fake provides a fake implementation of a Datastore client to use in testing.
+package fake
+
+import (
+	"context"
+	"log"
+	"reflect"
+
+	"cloud.google.com/go/datastore"
+	"github.com/googleapis/google-cloud-go-testing/datastore/dsiface"
+)
+
+// Client is a fake implementation of dsiface.Client to use in testing.
+type Client struct {
+	dsiface.Client
+
+	db map[string]map[string]interface{}
+}
+
+var _ dsiface.Client = &Client{}
+
+// Close is unimplemented and panics.
+func (f *Client) Close() error {
+	panic("unimplemented")
+}
+
+// AllocateIDs is unimplemented and panics.
+func (f *Client) AllocateIDs(context.Context, []*datastore.Key) ([]*datastore.Key, error) {
+	panic("unimplemented")
+}
+
+// Count is unimplemented and panics.
+func (f *Client) Count(context.Context, *datastore.Query) (n int, err error) {
+	panic("unimplemented")
+}
+
+// Delete is unimplemented and panics.
+func (f *Client) Delete(context.Context, *datastore.Key) error {
+	panic("unimplemented")
+}
+
+// DeleteMulti is unimplemented and panics.
+func (f *Client) DeleteMulti(context.Context, []*datastore.Key) (err error) {
+	panic("unimplemented")
+}
+
+// Get loads the entity stored for key into dst, which must be a struct pointer.
+func (f *Client) Get(_ context.Context, key *datastore.Key, dst interface{}) (err error) {
+	if f == nil {
+		return datastore.ErrNoSuchEntity
+	}
+	if dst == nil { // get catches nil interfaces; we need to catch nil ptr here
+		return datastore.ErrInvalidEntityType
+	}
+	kdb := f.db[key.Kind]
+	if kdb == nil {
+		return datastore.ErrNoSuchEntity
+	}
+	rv := reflect.ValueOf(dst)
+	if rv.Kind() != reflect.Ptr {
+		return datastore.ErrInvalidEntityType
+	}
+	rd := rv.Elem()
+	v := kdb[key.Encode()]
+	if v == nil {
+		return datastore.ErrNoSuchEntity
+	}
+	rd.Set(reflect.ValueOf(v).Elem())
+	return nil
+}
+
+// GetAll runs the provided query in the given context and returns all keys that match that query,
+// as well as appending the values to dst.
+//
+// GetAll currently only supports a query of all entities of a given Kind, and a dst of a slice of pointers to structs.
+func (f *Client) GetAll(_ context.Context, q *datastore.Query, dst interface{}) (keys []*datastore.Key, err error) {
+	fv := reflect.ValueOf(q).Elem().FieldByName("kind")
+	kdb := f.db[fv.String()]
+	if kdb == nil {
+		return
+	}
+	s := reflect.ValueOf(dst).Elem()
+	for k, v := range kdb {
+		dk, err := datastore.DecodeKey(k)
+		if err != nil {
+			log.Printf("f.GetAll() failed to decode key %q: %v", k, err)
+			continue
+		}
+		keys = append(keys, dk)
+		// This value is expected to represent a slice of pointers to structs.
+		// ev := reflect.New(s.Type().Elem().Elem())
+		// json.Unmarshal(v, ev.Interface())
+		s.Set(reflect.Append(s, reflect.ValueOf(v)))
+	}
+	return
+}
+
+// GetMulti is unimplemented and panics.
+func (f *Client) GetMulti(context.Context, []*datastore.Key, interface{}) (err error) {
+	panic("unimplemented")
+}
+
+// Mutate is unimplemented and panics.
+func (f *Client) Mutate(context.Context, ...*datastore.Mutation) (ret []*datastore.Key, err error) {
+	panic("unimplemented")
+}
+
+// NewTransaction is unimplemented and panics.
+func (f *Client) NewTransaction(context.Context, ...datastore.TransactionOption) (t dsiface.Transaction, err error) {
+	panic("unimplemented")
+}
+
+// Put saves the entity src into the datastore with the given key. src must be a struct pointer.
+func (f *Client) Put(_ context.Context, key *datastore.Key, src interface{}) (*datastore.Key, error) {
+	if f.db == nil {
+		f.db = make(map[string]map[string]interface{})
+	}
+	kdb := f.db[key.Kind]
+	if kdb == nil {
+		f.db[key.Kind] = make(map[string]interface{})
+		kdb = f.db[key.Kind]
+	}
+	kdb[key.Encode()] = src
+	return key, nil
+}
+
+// PutMulti is unimplemented and panics.
+func (f *Client) PutMulti(context.Context, []*datastore.Key, interface{}) (ret []*datastore.Key, err error) {
+	panic("unimplemented")
+}
+
+// Run is unimplemented and panics.
+func (f *Client) Run(context.Context, *datastore.Query) dsiface.Iterator {
+	panic("unimplemented")
+}
+
+// RunInTransaction is unimplemented and panics.
+func (f *Client) RunInTransaction(context.Context, func(tx dsiface.Transaction) error, ...datastore.TransactionOption) (cmt dsiface.Commit, err error) {
+	panic("unimplemented")
+}
diff --git a/cmd/relui/internal/datastore/fake/client_test.go b/cmd/relui/internal/datastore/fake/client_test.go
new file mode 100644
index 0000000..bb34a3d
--- /dev/null
+++ b/cmd/relui/internal/datastore/fake/client_test.go
@@ -0,0 +1,169 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package fake
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/datastore"
+	"github.com/google/go-cmp/cmp"
+)
+
+type author struct {
+	Name string
+}
+
+func TestClientGet(t *testing.T) {
+	cases := []struct {
+		desc    string
+		db      map[string]map[string]interface{}
+		key     *datastore.Key
+		dst     interface{}
+		want    *author
+		wantErr bool
+	}{
+		{
+			desc: "correct key",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			key:  datastore.NameKey("Author", "The Trial", nil),
+			dst:  new(author),
+			want: &author{Name: "Kafka"},
+		},
+		{
+			desc: "incorrect key errors",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			key:     datastore.NameKey("Author", "The Go Programming Language", nil),
+			dst:     new(author),
+			wantErr: true,
+		},
+		{
+			desc: "nil dst errors",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			key:     datastore.NameKey("Author", "The Go Programming Language", nil),
+			wantErr: true,
+		},
+		{
+			desc: "incorrect dst type errors",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			key:     datastore.NameKey("Author", "The Go Programming Language", nil),
+			dst:     &time.Time{},
+			wantErr: true,
+		},
+		{
+			desc: "non-pointer dst errors",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			key:     datastore.NameKey("Author", "The Go Programming Language", nil),
+			dst:     author{},
+			wantErr: true,
+		},
+		{
+			desc: "nil dst errors",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			key:     datastore.NameKey("Author", "The Go Programming Language", nil),
+			dst:     nil,
+			wantErr: true,
+		},
+		{
+			desc:    "empty db errors",
+			key:     datastore.NameKey("Author", "The Go Programming Language", nil),
+			dst:     nil,
+			wantErr: true,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			cl := &Client{db: c.db}
+
+			if err := cl.Get(context.Background(), c.key, c.dst); (err != nil) != c.wantErr {
+				t.Fatalf("cl.Get(_, %v, %v) = %q, wantErr: %v", c.key, c.dst, err, c.wantErr)
+			}
+			if c.wantErr {
+				return
+			}
+			if diff := cmp.Diff(c.want, c.dst); diff != "" {
+				t.Errorf("author mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestClientGetAll(t *testing.T) {
+	cases := []struct {
+		desc     string
+		db       map[string]map[string]interface{}
+		query    *datastore.Query
+		want     []*author
+		wantKeys []*datastore.Key
+		wantErr  bool
+	}{
+		{
+			desc: "all of a Kind",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			query:    datastore.NewQuery("Author"),
+			wantKeys: []*datastore.Key{datastore.NameKey("Author", "The Trial", nil)},
+			want:     []*author{{Name: "Kafka"}},
+		},
+		{
+			desc: "all of a non-existent kind",
+			db: map[string]map[string]interface{}{
+				"Author": {datastore.NameKey("Author", "The Trial", nil).Encode(): &author{Name: "Kafka"}},
+			},
+			query:   datastore.NewQuery("Book"),
+			wantErr: false,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			cl := &Client{db: c.db}
+
+			var got []*author
+			keys, err := cl.GetAll(context.Background(), c.query, &got)
+			if (err != nil) != c.wantErr {
+				t.Fatalf("cl.Getall(_, %v, %v) = %q, wantErr: %v", c.query, got, err, c.wantErr)
+			}
+			if diff := cmp.Diff(c.want, got); diff != "" {
+				t.Errorf("authors mismatch (-want +got):\n%s", diff)
+			}
+			if diff := cmp.Diff(c.wantKeys, keys); diff != "" {
+				t.Errorf("keys mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestClientPut(t *testing.T) {
+	cl := &Client{}
+	src := &author{Name: "Kafka"}
+	key := datastore.NameKey("Author", "The Trial", nil)
+
+	gotKey, err := cl.Put(context.Background(), key, src)
+	if err != nil {
+		t.Fatalf("cl.Put(_, %v, %v) = %v, %q, wanted no error", gotKey, key, src, err)
+	}
+	got := cl.db["Author"][key.Encode()]
+
+	if diff := cmp.Diff(src, got); diff != "" {
+		t.Errorf("author mismatch (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(key, gotKey); diff != "" {
+		t.Errorf("keys mismatch (-want +got):\n%s", diff)
+	}
+}
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
index 4e6e03c..b04013b 100644
--- a/cmd/relui/main.go
+++ b/cmd/relui/main.go
@@ -14,29 +14,31 @@
 	"os"
 	"path/filepath"
 
+	"cloud.google.com/go/datastore"
 	"cloud.google.com/go/pubsub"
 	"github.com/golang/protobuf/proto"
+	"github.com/googleapis/google-cloud-go-testing/datastore/dsiface"
 	reluipb "golang.org/x/build/cmd/relui/protos"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 )
 
 var (
-	devDataDir = flag.String("dev-data-directory", defaultDevDataDir(), "Development-only directory to use for storage of application state.")
-	projectID  = flag.String("project-id", os.Getenv("PUBSUB_PROJECT_ID"), "Pubsub project ID for communicating with workers. Uses PUBSUB_PROJECT_ID if unset.")
-	topicID    = flag.String("topic-id", "relui-development", "Pubsub topic ID for communicating with workers.")
+	projectID = flag.String("project-id", os.Getenv("PUBSUB_PROJECT_ID"), "Pubsub project ID for communicating with workers. Uses PUBSUB_PROJECT_ID if unset.")
+	topicID   = flag.String("topic-id", "relui-development", "Pubsub topic ID for communicating with workers.")
 )
 
 func main() {
 	flag.Parse()
-	fs := newFileStore(*devDataDir)
-	if err := fs.load(); err != nil {
-		log.Fatalf("Error loading state from %q: %v", *devDataDir, err)
-	}
 	ctx := context.Background()
+	dsc, err := datastore.NewClient(ctx, *projectID)
+	if err != nil {
+		log.Fatalf("datastore.NewClient(_, %q) = _, %v, wanted no error", *projectID, err)
+	}
+	d := &dsStore{client: dsiface.AdaptClient(dsc)}
 	s := &server{
 		configs: loadWorkflowConfig("./workflows"),
-		store:   fs,
+		store:   d,
 		topic:   getTopic(ctx),
 	}
 	http.Handle("/workflows/create", http.HandlerFunc(s.createWorkflowHandler))
@@ -91,12 +93,3 @@
 	}
 	return ws
 }
-
-// defaultDevDataDir returns a directory suitable for storage of data when developing relui on most platforms.
-func defaultDevDataDir() string {
-	c, err := os.UserConfigDir()
-	if err != nil {
-		c = os.TempDir()
-	}
-	return filepath.Join(c, "go-build", "relui")
-}
diff --git a/cmd/relui/protos/relui.pb.go b/cmd/relui/protos/relui.pb.go
index e85ff43..fde593f 100644
--- a/cmd/relui/protos/relui.pb.go
+++ b/cmd/relui/protos/relui.pb.go
@@ -54,8 +54,8 @@
 	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
 	// buildable_asks is a list of tasks to be performed by the workflow.
 	BuildableTasks []*BuildableTask `protobuf:"bytes,2,rep,name=buildable_tasks,json=buildableTasks,proto3" json:"buildable_tasks,omitempty"`
-	// params are parameters provided when creating a workflow.
-	Params map[string]string `protobuf:"bytes,3,rep,name=params,proto3" json:"params,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+	// git_source is an optional configuration for which git source to fetch.
+	GitSource *GitSource `protobuf:"bytes,3,opt,name=git_source,json=gitSource,proto3" json:"git_source,omitempty"`
 	// id is a unique identifier generated by relui when a workflow is created.
 	Id                   string   `protobuf:"bytes,4,opt,name=id,proto3" json:"id,omitempty"`
 	XXX_NoUnkeyedLiteral struct{} `json:"-"`
@@ -102,9 +102,9 @@
 	return nil
 }
 
-func (m *Workflow) GetParams() map[string]string {
+func (m *Workflow) GetGitSource() *GitSource {
 	if m != nil {
-		return m.Params
+		return m.GitSource
 	}
 	return nil
 }
@@ -363,7 +363,6 @@
 func init() {
 	proto.RegisterEnum("protos.TaskStatus", TaskStatus_name, TaskStatus_value)
 	proto.RegisterType((*Workflow)(nil), "protos.Workflow")
-	proto.RegisterMapType((map[string]string)(nil), "protos.Workflow.ParamsEntry")
 	proto.RegisterType((*BuildableTask)(nil), "protos.BuildableTask")
 	proto.RegisterType((*StartBuildableTaskRequest)(nil), "protos.StartBuildableTaskRequest")
 	proto.RegisterType((*LocalStorage)(nil), "protos.LocalStorage")
@@ -373,36 +372,33 @@
 func init() { proto.RegisterFile("relui.proto", fileDescriptor_6de8859f82adce0a) }
 
 var fileDescriptor_6de8859f82adce0a = []byte{
-	// 486 bytes of a gzipped FileDescriptorProto
-	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x53, 0xdd, 0x6e, 0xd3, 0x30,
-	0x14, 0xc6, 0xe9, 0x56, 0x96, 0x93, 0xd1, 0xb5, 0x1e, 0x88, 0xf0, 0x27, 0x4a, 0xae, 0xaa, 0x5e,
-	0x04, 0x54, 0xb8, 0x00, 0x2e, 0x26, 0x15, 0xa8, 0xd0, 0x34, 0xd4, 0xa1, 0x24, 0x55, 0x2f, 0x23,
-	0xb7, 0x71, 0xab, 0xa8, 0x5e, 0x12, 0x6c, 0x87, 0xa9, 0x6f, 0xc2, 0xdb, 0xf1, 0x0a, 0x3c, 0x02,
-	0xb2, 0x6b, 0xb3, 0xa6, 0xe2, 0x2a, 0xc7, 0xe7, 0xfb, 0x7c, 0xfc, 0x9d, 0xef, 0x9c, 0x80, 0xc7,
-	0x29, 0xab, 0xf3, 0xb0, 0xe2, 0xa5, 0x2c, 0x71, 0x5b, 0x7f, 0x44, 0xf0, 0x1b, 0xc1, 0xc9, 0xbc,
-	0xe4, 0x9b, 0x15, 0x2b, 0x6f, 0x31, 0x86, 0xa3, 0x82, 0xdc, 0x50, 0x1f, 0xf5, 0xd1, 0xc0, 0x8d,
-	0x74, 0x8c, 0x2f, 0xe0, 0x6c, 0x51, 0xe7, 0x2c, 0x23, 0x0b, 0x46, 0x53, 0x49, 0xc4, 0x46, 0xf8,
-	0x4e, 0xbf, 0x35, 0xf0, 0x46, 0x8f, 0x76, 0x95, 0x44, 0xf8, 0xc9, 0xc2, 0x09, 0x11, 0x9b, 0xa8,
-	0xb3, 0xd8, 0x3f, 0x0a, 0xfc, 0x0e, 0xda, 0x15, 0xe1, 0xe4, 0x46, 0xf8, 0x2d, 0x7d, 0xed, 0xb9,
-	0xbd, 0x66, 0x5f, 0x0d, 0xbf, 0x6b, 0x78, 0x52, 0x48, 0xbe, 0x8d, 0x0c, 0x17, 0x77, 0xc0, 0xc9,
-	0x33, 0xff, 0x48, 0xeb, 0x70, 0xf2, 0xec, 0xe9, 0x07, 0xf0, 0xf6, 0x68, 0xb8, 0x0b, 0xad, 0x0d,
-	0xdd, 0x1a, 0x9d, 0x2a, 0xc4, 0x0f, 0xe1, 0xf8, 0x27, 0x61, 0x35, 0xf5, 0x1d, 0x9d, 0xdb, 0x1d,
-	0x3e, 0x3a, 0xef, 0x51, 0xf0, 0x07, 0xc1, 0x83, 0x86, 0xc4, 0xff, 0xb6, 0xf9, 0x02, 0x20, 0xa3,
-	0x15, 0x2d, 0x32, 0x91, 0x96, 0x85, 0x29, 0xe2, 0x9a, 0xcc, 0x75, 0x81, 0x87, 0xd0, 0x16, 0x92,
-	0xc8, 0x5a, 0x75, 0x81, 0x06, 0x9d, 0x11, 0xb6, 0x5d, 0xa8, 0x82, 0xb1, 0x46, 0x22, 0xc3, 0xc0,
-	0xaf, 0xe0, 0x94, 0x70, 0x99, 0xaf, 0xc8, 0x52, 0xa6, 0x35, 0x67, 0xa6, 0x0b, 0xcf, 0xe6, 0x66,
-	0x9c, 0xe1, 0x37, 0x00, 0xeb, 0x5c, 0xa6, 0xa2, 0xac, 0xf9, 0x92, 0xfa, 0xc7, 0x7d, 0x34, 0xf0,
-	0x46, 0x3d, 0x5b, 0xf2, 0x6b, 0x2e, 0x63, 0x0d, 0x44, 0xee, 0xda, 0x86, 0xf8, 0x19, 0xb8, 0xca,
-	0xfc, 0x54, 0x6e, 0x2b, 0xea, 0xb7, 0x75, 0xc5, 0x13, 0x95, 0x48, 0xb6, 0x15, 0x35, 0x6e, 0xdd,
-	0xb7, 0x6e, 0x05, 0xbf, 0x10, 0x3c, 0x89, 0x25, 0xe1, 0xb2, 0x39, 0x1a, 0xfa, 0xa3, 0xa6, 0x42,
-	0xe2, 0x97, 0xe0, 0xdd, 0x1a, 0xef, 0xd3, 0x3c, 0x33, 0x2e, 0x80, 0x4d, 0x5d, 0x66, 0x78, 0x08,
-	0xbd, 0xe6, 0xc8, 0x15, 0x6d, 0x67, 0xc9, 0x59, 0x63, 0xba, 0x97, 0x19, 0x0e, 0xe1, 0xfc, 0x80,
-	0xab, 0x15, 0xb6, 0x34, 0xbb, 0xd7, 0x60, 0x2b, 0xa9, 0xc1, 0x05, 0x9c, 0x7e, 0x2b, 0x97, 0x84,
-	0xc5, 0xb2, 0xe4, 0x64, 0x4d, 0x71, 0x08, 0xae, 0x7d, 0x59, 0xf8, 0x48, 0x6f, 0x48, 0xf7, 0x70,
-	0x43, 0xa2, 0x3b, 0x4a, 0xf0, 0x1a, 0xdc, 0x7f, 0xfe, 0xa8, 0x35, 0x50, 0x06, 0x9b, 0x35, 0xa8,
-	0x39, 0x53, 0x19, 0x4e, 0x57, 0x46, 0xac, 0x0a, 0x87, 0x73, 0x80, 0xbb, 0x19, 0xe1, 0xc7, 0x70,
-	0x9e, 0x8c, 0xe3, 0xab, 0x34, 0x4e, 0xc6, 0xc9, 0x2c, 0x4e, 0x67, 0xd3, 0xab, 0xe9, 0xf5, 0x7c,
-	0xda, 0xbd, 0x77, 0x08, 0x7c, 0x8e, 0x26, 0xe3, 0x64, 0xf2, 0xa5, 0x8b, 0x0e, 0x81, 0x38, 0x19,
-	0x47, 0x0a, 0x70, 0x16, 0xbb, 0x3f, 0xe8, 0xed, 0xdf, 0x00, 0x00, 0x00, 0xff, 0xff, 0xd3, 0xc2,
-	0xf2, 0x91, 0x57, 0x03, 0x00, 0x00,
+	// 434 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x53, 0xd1, 0x6e, 0xd3, 0x30,
+	0x14, 0xc5, 0xe9, 0x28, 0xcb, 0xcd, 0xe8, 0x5a, 0x4f, 0x88, 0x20, 0x84, 0x28, 0x7d, 0xaa, 0xfa,
+	0x50, 0x50, 0x79, 0x9f, 0x54, 0x60, 0x42, 0xd3, 0x50, 0x27, 0xc5, 0xa9, 0xfa, 0x68, 0xb9, 0xb5,
+	0x5b, 0x59, 0x0d, 0x75, 0xb1, 0x1d, 0x4d, 0xfb, 0x13, 0x1e, 0xf9, 0x44, 0x3e, 0x01, 0xd9, 0x89,
+	0x37, 0x12, 0xf1, 0xc0, 0x53, 0x6e, 0xee, 0x39, 0xb9, 0x3e, 0xe7, 0x5c, 0x07, 0x12, 0x2d, 0x8a,
+	0x52, 0x4e, 0x8f, 0x5a, 0x59, 0x85, 0xbb, 0xfe, 0x61, 0x46, 0xbf, 0x10, 0x9c, 0xae, 0x94, 0xde,
+	0x6f, 0x0b, 0x75, 0x87, 0x31, 0x9c, 0x1c, 0xd8, 0x77, 0x91, 0xa2, 0x21, 0x1a, 0xc7, 0x99, 0xaf,
+	0xf1, 0x25, 0x9c, 0xaf, 0x4b, 0x59, 0x70, 0xb6, 0x2e, 0x04, 0xb5, 0xcc, 0xec, 0x4d, 0x1a, 0x0d,
+	0x3b, 0xe3, 0x64, 0xf6, 0xa2, 0x9a, 0x64, 0xa6, 0x9f, 0x02, 0x9c, 0x33, 0xb3, 0xcf, 0x7a, 0xeb,
+	0xbf, 0x5f, 0x0d, 0xfe, 0x00, 0xb0, 0x93, 0x96, 0x1a, 0x55, 0xea, 0x8d, 0x48, 0x3b, 0x43, 0x34,
+	0x4e, 0x66, 0x83, 0xf0, 0xe9, 0x57, 0x69, 0x89, 0x07, 0xb2, 0x78, 0x17, 0x4a, 0xdc, 0x83, 0x48,
+	0xf2, 0xf4, 0xc4, 0x6b, 0x88, 0x24, 0x1f, 0xfd, 0x46, 0xf0, 0xbc, 0x71, 0xc6, 0x3f, 0x75, 0xbe,
+	0x01, 0xe0, 0xe2, 0x28, 0x0e, 0xdc, 0x50, 0x75, 0x48, 0x23, 0x8f, 0xc4, 0x75, 0xe7, 0xf6, 0x80,
+	0x27, 0xd0, 0x35, 0x96, 0xd9, 0xd2, 0x78, 0x09, 0xbd, 0x19, 0x0e, 0x12, 0xdc, 0x40, 0xe2, 0x91,
+	0xac, 0x66, 0xe0, 0x77, 0x70, 0xc6, 0xb4, 0x95, 0x5b, 0xb6, 0xb1, 0xb4, 0xd4, 0x45, 0x2d, 0x25,
+	0x09, 0xbd, 0xa5, 0x2e, 0x5a, 0xae, 0x9e, 0xfe, 0x87, 0xab, 0xd7, 0x10, 0xbb, 0xf4, 0xa8, 0xbd,
+	0x3f, 0x8a, 0xb4, 0xeb, 0x27, 0x9e, 0xba, 0x46, 0x7e, 0x7f, 0x0c, 0x96, 0x9f, 0x3d, 0x58, 0xfe,
+	0x89, 0xe0, 0x15, 0xb1, 0x4c, 0xdb, 0x66, 0xb6, 0xe2, 0x47, 0x29, 0x8c, 0xc5, 0x6f, 0x21, 0xb9,
+	0xab, 0x57, 0x46, 0x25, 0xaf, 0x53, 0x80, 0xd0, 0xba, 0xe6, 0x78, 0x02, 0x83, 0xe6, 0xce, 0x1c,
+	0xad, 0x8a, 0xe4, 0xbc, 0xb1, 0x9e, 0x6b, 0x8e, 0xa7, 0x70, 0xd1, 0xe2, 0x7a, 0x85, 0x1d, 0xcf,
+	0x1e, 0x34, 0xd8, 0x4e, 0xea, 0xe8, 0x12, 0xce, 0xbe, 0xa9, 0x0d, 0x2b, 0x88, 0x55, 0x9a, 0xed,
+	0x04, 0x9e, 0x42, 0x1c, 0x4e, 0x36, 0x29, 0xf2, 0x37, 0xa3, 0x1f, 0x82, 0x08, 0x17, 0x2b, 0x7b,
+	0xa4, 0x8c, 0xde, 0x43, 0xfc, 0x90, 0x0f, 0xee, 0x43, 0xc7, 0x05, 0x5c, 0x39, 0x70, 0xa5, 0xeb,
+	0x68, 0xb1, 0xad, 0xc5, 0xba, 0x72, 0xb2, 0x02, 0x78, 0xdc, 0x11, 0x7e, 0x09, 0x17, 0xf9, 0x9c,
+	0xdc, 0x50, 0x92, 0xcf, 0xf3, 0x25, 0xa1, 0xcb, 0xc5, 0xcd, 0xe2, 0x76, 0xb5, 0xe8, 0x3f, 0x69,
+	0x03, 0x9f, 0xb3, 0xab, 0x79, 0x7e, 0xf5, 0xa5, 0x8f, 0xda, 0x00, 0xc9, 0xe7, 0x99, 0x03, 0xa2,
+	0x75, 0xf5, 0x0b, 0x7c, 0xfc, 0x13, 0x00, 0x00, 0xff, 0xff, 0xa6, 0xee, 0x56, 0xcb, 0x18, 0x03,
+	0x00, 0x00,
 }
diff --git a/cmd/relui/protos/relui.proto b/cmd/relui/protos/relui.proto
index 49f3df6..933a1cf 100644
--- a/cmd/relui/protos/relui.proto
+++ b/cmd/relui/protos/relui.proto
@@ -14,8 +14,8 @@
   // buildable_asks is a list of tasks to be performed by the workflow.
   repeated BuildableTask buildable_tasks = 2;
 
-  // params are parameters provided when creating a workflow.
-  map<string, string> params = 3;
+  // git_source is an optional configuration for which git source to fetch.
+  GitSource git_source = 3;
 
   // id is a unique identifier generated by relui when a workflow is created.
   string id = 4;
diff --git a/cmd/relui/store.go b/cmd/relui/store.go
index 8dc838c..26edf32 100644
--- a/cmd/relui/store.go
+++ b/cmd/relui/store.go
@@ -5,17 +5,15 @@
 package main
 
 import (
-	"fmt"
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"sync"
+	"context"
+	"log"
 
-	"github.com/golang/protobuf/proto"
+	"cloud.google.com/go/datastore"
+	"github.com/googleapis/google-cloud-go-testing/datastore/dsiface"
 	reluipb "golang.org/x/build/cmd/relui/protos"
 )
 
-// store is a persistence adapter for saving data.
+// store is a persistence interface for saving data.
 type store interface {
 	AddWorkflow(workflow *reluipb.Workflow) error
 	BuildableTask(workflowId, id string) *reluipb.BuildableTask
@@ -23,106 +21,48 @@
 	Workflows() []*reluipb.Workflow
 }
 
-var _ store = (*fileStore)(nil)
+var _ store = (*dsStore)(nil)
 
-// newFileStore initializes a fileStore ready for use.
-//
-// If dir is set to an empty string (""), no data will be saved to disk.
-func newFileStore(dir string) *fileStore {
-	return &fileStore{
-		persistDir: dir,
-		ls:         new(reluipb.LocalStorage),
-	}
+// dsStore is a store backed by Google Cloud Datastore.
+type dsStore struct {
+	client dsiface.Client
 }
 
-// fileStoreName is the name of the data file used by fileStore for persistence.
-const fileStoreName = "local_storage.textpb"
-
-// fileStore is a non-durable implementation of store that keeps everything in memory.
-type fileStore struct {
-	mu sync.Mutex
-	ls *reluipb.LocalStorage
-
-	// persistDir is a path to a directory for saving application data in textproto format.
-	// Set persistDir to an empty string to disable saving and loading from the filesystem.
-	persistDir string
+// AddWorkflow adds a reluipb.Workflow to the database.
+func (d *dsStore) AddWorkflow(wf *reluipb.Workflow) error {
+	key := datastore.NameKey("Workflow", wf.GetId(), nil)
+	_, err := d.client.Put(context.TODO(), key, wf)
+	return err
 }
 
-// AddWorkflow adds a workflow to the store, persisting changes to disk.
-func (f *fileStore) AddWorkflow(w *reluipb.Workflow) error {
-	f.mu.Lock()
-	f.ls.Workflows = append(f.ls.Workflows, w)
-	f.mu.Unlock()
-	if err := f.persist(); err != nil {
-		return err
-	}
-	return nil
-}
-
-// Workflows returns all reluipb.Workflows stored.
-func (f *fileStore) Workflows() []*reluipb.Workflow {
-	return f.localStorage().GetWorkflows()
-}
-
-// Workflow returns a single reluipb.Workflow found by its id. If it is not found, it returns nil.
-func (f *fileStore) Workflow(id string) *reluipb.Workflow {
-	for _, w := range f.Workflows() {
-		if w.GetId() == id {
-			return w
+// BuildableTask fetches a reluipb.BuildableTask from the database.
+func (d *dsStore) BuildableTask(workflowId, id string) *reluipb.BuildableTask {
+	wf := d.Workflow(workflowId)
+	for _, bt := range wf.GetBuildableTasks() {
+		if bt.GetId() == id {
+			return bt
 		}
 	}
 	return nil
 }
 
-// BuildableTask returns a single reluipb.BuildableTask found by the reluipb.Workflow id and its id.
-// If it is not found, it returns nil.
-func (f *fileStore) BuildableTask(workflowId, id string) *reluipb.BuildableTask {
-	wf := f.Workflow(workflowId)
-	for _, t := range wf.GetBuildableTasks() {
-		if t.GetId() == id {
-			return t
-		}
-	}
-	return nil
-}
-
-// localStorage returns a deep copy of data stored in fileStore.
-func (f *fileStore) localStorage() *reluipb.LocalStorage {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return proto.Clone(f.ls).(*reluipb.LocalStorage)
-}
-
-// persist saves fileStore state to persistDir/fileStoreName.
-func (f *fileStore) persist() error {
-	if f.persistDir == "" {
+// Workflow fetches a reluipb.Workflow from the database.
+func (d *dsStore) Workflow(id string) *reluipb.Workflow {
+	key := datastore.NameKey("Workflow", id, nil)
+	wf := new(reluipb.Workflow)
+	if err := d.client.Get(context.TODO(), key, wf); err != nil {
+		log.Printf("d.client.Get(_, %q, %v) = %v", key, wf, err)
 		return nil
 	}
-	if err := os.MkdirAll(f.persistDir, 0755); err != nil {
-		return fmt.Errorf("os.MkDirAll(%q, %v) = %w", f.persistDir, 0755, err)
-	}
-	dst := filepath.Join(f.persistDir, fileStoreName)
-	data := []byte(proto.MarshalTextString(f.localStorage()))
-	if err := ioutil.WriteFile(dst, data, 0644); err != nil {
-		return fmt.Errorf("ioutil.WriteFile(%q, _, %v) = %w", dst, 0644, err)
-	}
-	return nil
+	return wf
 }
 
-// load reads fileStore state from persistDir/fileStoreName.
-func (f *fileStore) load() error {
-	if f.persistDir == "" {
+// Workflows returns all reluipb.Workflow entities from the database.
+func (d *dsStore) Workflows() []*reluipb.Workflow {
+	var wfs []*reluipb.Workflow
+	if _, err := d.client.GetAll(context.TODO(), datastore.NewQuery("Workflow"), &wfs); err != nil {
+		log.Printf("d.client.GetAll(_, %#v, %v) = %v", datastore.NewQuery("Workflow"), &wfs, err)
 		return nil
 	}
-	path := filepath.Join(f.persistDir, fileStoreName)
-	b, err := ioutil.ReadFile(path)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil
-		}
-		return fmt.Errorf("ioutil.ReadFile(%q) = _, %v", path, err)
-	}
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return proto.UnmarshalText(string(b), f.ls)
+	return wfs
 }
diff --git a/cmd/relui/store_test.go b/cmd/relui/store_test.go
deleted file mode 100644
index db5fcd3..0000000
--- a/cmd/relui/store_test.go
+++ /dev/null
@@ -1,144 +0,0 @@
-// Copyright 2020 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package main
-
-import (
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/golang/protobuf/proto"
-	"github.com/google/go-cmp/cmp"
-	reluipb "golang.org/x/build/cmd/relui/protos"
-)
-
-func TestFileStorePersist(t *testing.T) {
-	dir, err := ioutil.TempDir("", "fileStore-test")
-	if err != nil {
-		t.Fatalf("ioutil.TempDir(%q, %q) = _, %v", "", "fileStore-test", err)
-	}
-	defer os.RemoveAll(dir)
-	want := &reluipb.LocalStorage{
-		Workflows: []*reluipb.Workflow{
-			{
-				Name:           "Persist Test",
-				BuildableTasks: []*reluipb.BuildableTask{{Name: "Persist Test Task"}},
-			},
-		},
-	}
-	fs := newFileStore(filepath.Join(dir, "relui"))
-	fs.ls = want
-
-	err = fs.persist()
-	if err != nil {
-		t.Fatalf("fs.Persist() = %v, wanted no error", err)
-	}
-
-	b, err := ioutil.ReadFile(filepath.Join(dir, "relui", fileStoreName))
-	if err != nil {
-		t.Fatalf("ioutil.ReadFile(%q) = _, %v, wanted no error", filepath.Join(dir, "relui", fileStoreName), err)
-	}
-	got := new(reluipb.LocalStorage)
-	err = proto.UnmarshalText(string(b), got)
-	if err != nil {
-		t.Fatalf("proto.UnmarshalText(_) = %v, wanted no error", err)
-	}
-	if diff := cmp.Diff(want, got); diff != "" {
-		t.Errorf("reluipb.LocalStorage mismatch (-want, +got):\n%s", diff)
-	}
-}
-
-func TestFileStoreLoad(t *testing.T) {
-	dir, err := ioutil.TempDir("", "fileStore-test")
-	if err != nil {
-		t.Fatalf("ioutil.TempDir(%q, %q) = _, %v", "", "fileStore-test", err)
-	}
-	defer os.RemoveAll(dir)
-	if err := os.MkdirAll(filepath.Join(dir, "relui"), 0755); err != nil {
-		t.Errorf("os.MkDirAll(%q, %v) = %w", filepath.Join(dir, "relui"), 0755, err)
-	}
-	want := &reluipb.LocalStorage{
-		Workflows: []*reluipb.Workflow{
-			{
-				Name:           "Load Test",
-				BuildableTasks: []*reluipb.BuildableTask{{Name: "Load Test Task"}},
-			},
-		},
-	}
-	data := []byte(proto.MarshalTextString(want))
-	dst := filepath.Join(dir, "relui", fileStoreName)
-	if err := ioutil.WriteFile(dst, data, 0644); err != nil {
-		t.Fatalf("ioutil.WriteFile(%q, _, %v) = %v", dst, 0644, err)
-	}
-
-	fs := newFileStore(filepath.Join(dir, "relui"))
-	if err := fs.load(); err != nil {
-		t.Errorf("reluipb.load() = %v, wanted no error", err)
-	}
-
-	if diff := cmp.Diff(want, fs.localStorage()); diff != "" {
-		t.Errorf("reluipb.LocalStorage mismatch (-want, +got):\n%s", diff)
-	}
-}
-
-func TestFileStoreLoadErrors(t *testing.T) {
-	empty, err := ioutil.TempDir("", "fileStoreLoad")
-	if err != nil {
-		t.Fatalf("ioutil.TempDir(%q, %q) = %v, wanted no error", "", "fileStoreLoad", err)
-	}
-	defer os.RemoveAll(empty)
-
-	collision, err := ioutil.TempDir("", "fileStoreLoad")
-	if err != nil {
-		t.Fatalf("ioutil.TempDir(%q, %q) = %v, wanted no error", "", "fileStoreLoad", err)
-	}
-	defer os.RemoveAll(collision)
-	// We want to trigger an error when trying to read the file, so make a directory with the same name.
-	if err := os.MkdirAll(filepath.Join(collision, fileStoreName), 0755); err != nil {
-		t.Errorf("os.MkDirAll(%q, %v) = %w", filepath.Join(collision, fileStoreName), 0755, err)
-	}
-
-	corrupt, err := ioutil.TempDir("", "fileStoreLoad")
-	if err != nil {
-		t.Fatalf("ioutil.TempDir(%q, %q) = %v, wanted no error", "", "fileStoreLoad", err)
-	}
-	defer os.RemoveAll(corrupt)
-	if err := ioutil.WriteFile(filepath.Join(corrupt, fileStoreName), []byte("oh no"), 0644); err != nil {
-		t.Fatalf("ioutil.WriteFile(%q, %q, %v) = %v, wanted no error", filepath.Join(corrupt, fileStoreName), "oh no", 0644, err)
-	}
-
-	cases := []struct {
-		desc    string
-		dir     string
-		wantErr bool
-	}{
-		{
-			desc: "no persistDir configured",
-		},
-		{
-			desc: "no file in persistDir",
-			dir:  empty,
-		},
-		{
-			desc:    "other error reading file",
-			dir:     collision,
-			wantErr: true,
-		},
-		{
-			desc:    "corrupt data in persistDir",
-			dir:     corrupt,
-			wantErr: true,
-		},
-	}
-	for _, c := range cases {
-		t.Run(c.desc, func(t *testing.T) {
-			f := newFileStore(c.dir)
-			if err := f.load(); (err != nil) != c.wantErr {
-				t.Errorf("f.load() = %v, wantErr = %t", err, c.wantErr)
-			}
-		})
-	}
-}
diff --git a/cmd/relui/templates/home.html b/cmd/relui/templates/home.html
index c230dd5..9240f95 100644
--- a/cmd/relui/templates/home.html
+++ b/cmd/relui/templates/home.html
@@ -12,7 +12,7 @@
   <ul class="WorkflowList">
     {{range $workflow := .Workflows}}
     <li class="WorkflowList-item">
-      <h3>{{$workflow.Name}} - {{index $workflow.GetParams "GitObject"}}</h3>
+      <h3>{{$workflow.Name}} - {{index $workflow.GitSource}}</h3>
       <h4 class="WorkflowList-sectionTitle">Tasks</h4>
       <ul class="TaskList">
         {{range $task := $workflow.BuildableTasks}}
diff --git a/cmd/relui/web.go b/cmd/relui/web.go
index 3a9b7fd..ae32970 100644
--- a/cmd/relui/web.go
+++ b/cmd/relui/web.go
@@ -106,14 +106,11 @@
 	}
 	// Always create the first workflow for now, until we have more.
 	wf := proto.Clone(s.configs[0]).(*reluipb.Workflow)
-	if wf.GetParams() == nil {
-		wf.Params = map[string]string{}
-	}
 	wf.Id = uuid.New().String()
 	for _, t := range wf.GetBuildableTasks() {
 		t.Id = uuid.New().String()
 	}
-	wf.Params["GitObject"] = ref
+	wf.GitSource = &reluipb.GitSource{Ref: ref}
 	if err := s.store.AddWorkflow(wf); err != nil {
 		log.Printf("Error adding workflow: s.store.AddWorkflow(%v) = %v", wf, err)
 		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
diff --git a/cmd/relui/web_test.go b/cmd/relui/web_test.go
index 54991d5..8ab6428 100644
--- a/cmd/relui/web_test.go
+++ b/cmd/relui/web_test.go
@@ -15,7 +15,7 @@
 
 	"cloud.google.com/go/pubsub"
 	"cloud.google.com/go/pubsub/pstest"
-	"github.com/google/go-cmp/cmp"
+	"golang.org/x/build/cmd/relui/internal/datastore/fake"
 	reluipb "golang.org/x/build/cmd/relui/protos"
 	"google.golang.org/api/option"
 	"google.golang.org/grpc"
@@ -88,7 +88,7 @@
 	req := httptest.NewRequest(http.MethodGet, "/", nil)
 	w := httptest.NewRecorder()
 
-	s := &server{store: newFileStore("")}
+	s := &server{store: &dsStore{client: &fake.Client{}}}
 	s.homeHandler(w, req)
 	resp := w.Result()
 
@@ -101,7 +101,7 @@
 	req := httptest.NewRequest(http.MethodGet, "/workflows/new", nil)
 	w := httptest.NewRecorder()
 
-	s := &server{store: newFileStore("")}
+	s := &server{store: &dsStore{client: &fake.Client{}}}
 	s.newWorkflowHandler(w, req)
 	resp := w.Result()
 
@@ -122,7 +122,6 @@
 		params      url.Values
 		wantCode    int
 		wantHeaders map[string]string
-		wantParams  map[string]string
 	}{
 		{
 			desc:     "bad request",
@@ -135,7 +134,6 @@
 			wantHeaders: map[string]string{
 				"Location": "/",
 			},
-			wantParams: map[string]string{"GitObject": "abc"},
 		},
 	}
 	for _, c := range cases {
@@ -144,7 +142,7 @@
 			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 			w := httptest.NewRecorder()
 
-			s := &server{store: newFileStore(""), configs: config}
+			s := &server{store: &dsStore{client: &fake.Client{}}, configs: config}
 			s.createWorkflowHandler(w, req)
 			resp := w.Result()
 
@@ -156,22 +154,18 @@
 					t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
 				}
 			}
-			if len(s.store.Workflows()) != 1 && c.wantParams != nil {
-				t.Fatalf("len(s.store.Workflows()) = %d, wanted %d", len(s.store.Workflows()), 1)
-			} else if len(s.store.Workflows()) != 0 && c.wantParams == nil {
-				t.Fatalf("len(s.store.Workflows()) = %d, wanted %d", len(s.store.Workflows()), 0)
-			}
-			if c.wantParams == nil {
+			if c.wantCode == http.StatusBadRequest {
 				return
 			}
-			if diff := cmp.Diff(c.wantParams, s.store.Workflows()[0].GetParams()); diff != "" {
-				t.Errorf("s.Store.Workflows()[0].Params() mismatch (-want, +got):\n%s", diff)
+			wfs := s.store.Workflows()
+			if len(wfs) != 1 {
+				t.Fatalf("len(wfs) = %d, wanted %d", len(wfs), 1)
 			}
-			if s.store.Workflows()[0].GetId() == "" {
-				t.Errorf("s.Store.Workflows[0].GetId() = %q, wanted not empty", s.store.Workflows()[0].GetId())
+			if wfs[0].GetId() == "" {
+				t.Errorf("s.Store.Workflows[0].GetId() = %q, wanted not empty", wfs[0].GetId())
 			}
-			if s.store.Workflows()[0].GetBuildableTasks()[0].GetId() == "" {
-				t.Errorf("s.Store.Workflows[0].GetBuildableTasks()[0].GetId() = %q, wanted not empty", s.store.Workflows()[0].GetId())
+			if wfs[0].GetBuildableTasks()[0].GetId() == "" {
+				t.Errorf("s.Store.Workflows[0].GetBuildableTasks()[0].GetId() = %q, wanted not empty", wfs[0].GetId())
 			}
 		})
 	}
@@ -211,7 +205,7 @@
 		t.Fatalf("client.CreateTopic(_, %q) = _, %v", "relui-test-topic", err)
 	}
 
-	s := server{store: newFileStore(""), topic: topic}
+	s := server{store: &dsStore{client: &fake.Client{}}, topic: topic}
 	wf := &reluipb.Workflow{
 		Id:   "someworkflow",
 		Name: "test_workflow",
@@ -220,7 +214,6 @@
 			TaskType: "TestTask",
 			Id:       "sometask",
 		}},
-		Params: map[string]string{"GitObject": "master"},
 	}
 	if s.store.AddWorkflow(wf) != nil {
 		t.Fatalf("store.AddWorkflow(%v) = %v, wanted no error", wf, err)
@@ -262,7 +255,6 @@
 			TaskType: "TestTask",
 			Id:       "sometask",
 		}},
-		Params: map[string]string{"GitObject": "master"},
 	}
 
 	cases := []struct {
@@ -294,7 +286,7 @@
 			// Simulate pubsub failure by stopping publishing.
 			topic.Stop()
 
-			s := server{store: newFileStore(""), topic: topic}
+			s := server{store: &dsStore{client: &fake.Client{}}, topic: topic}
 			if s.store.AddWorkflow(wf) != nil {
 				t.Fatalf("store.AddWorkflow(%v) = %v, wanted no error", wf, err)
 			}
diff --git a/cmd/relui/workflows/local_go_release.textpb b/cmd/relui/workflows/local_go_release.textpb
index 06e1b0f..6ab1e58 100644
--- a/cmd/relui/workflows/local_go_release.textpb
+++ b/cmd/relui/workflows/local_go_release.textpb
@@ -2,13 +2,12 @@
 # proto-message: Workflow
 
 name: "local_go_release"
+git_source: {
+  url: "https://go.googlesource.com/go"
+}
 buildable_tasks: [
   {
     name: "fetch_go_source"
     task_type: "FetchGitSource"
-    git_source: {
-      ref: "master"
-      url: "https://go.googlesource.com/go"
-    }
   }
 ]
diff --git a/go.mod b/go.mod
index 9f0aba7..b6e483a 100644
--- a/go.mod
+++ b/go.mod
@@ -22,6 +22,7 @@
 	github.com/google/go-querystring v1.0.0 // indirect
 	github.com/google/uuid v1.1.2
 	github.com/googleapis/gax-go/v2 v2.0.5
+	github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8
 	github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7
 	github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1
 	github.com/kr/pty v1.1.3
diff --git a/go.sum b/go.sum
index afdca40..e2473b6 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,7 @@
 cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
 cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
 cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
 cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
 cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
 cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
@@ -118,6 +119,8 @@
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 h1:tlyzajkF3030q6M8SvmJSemC9DTHL/xaMa18b65+JM4=
+github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
 github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=