maintner: add RPC endpoint for listing Go releases

This change adds an RPC endpoint to maintnerd API server to list
the supported Go releases. This is needed and will be used by
cmd/coordinator to run subrepo trybots on previous Go release
(in addition to current).

It's implemented on top of the Gerrit project ref data that the
maintner corpus already tracks. A release is considered to be
exist for each git tag named "goX", "goX.Y", or "goX.Y.Z".

This functionality is also implemented in some other places using
data that is further away from the source of truth. Such places can
start to use this endpoint instead.

Add a subcommand to maintq to invoke the new list Go releases endpoint.
Its current output (against a local development maintnerd server):

	$ go run .../maintner/maintq -server=localhost:6344 list-releases
	major:1 minor:11 patch:1 tag_name:"go1.11.1" tag_commit:"26957168c4c0cdcc7ca4f0b19d0eb19474d224ac" branch_name:"release-branch.go1.11" branch_commit:"97781d2ed116d2cd9cb870d0b84fc0ec598c9abc"
	major:1 minor:10 patch:4 tag_name:"go1.10.4" tag_commit:"2191fce26a7fd1cd5b4975e7bd44ab44b1d9dd78" branch_name:"release-branch.go1.10" branch_commit:"e97b7d68f107ff60152f5bd5701e0286f221ee93"

Updates golang/go#17626

Change-Id: Ia9ea7f49d421ce0c7a9e85c423aba31572cea52b
Reviewed-on: https://go-review.googlesource.com/c/146137
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/maintner/maintnerd/apipb/api.pb.go b/maintner/maintnerd/apipb/api.pb.go
index 80faccf..93a76bd 100644
--- a/maintner/maintnerd/apipb/api.pb.go
+++ b/maintner/maintnerd/apipb/api.pb.go
@@ -15,6 +15,9 @@
 	GoFindTryWorkRequest
 	GoFindTryWorkResponse
 	GerritTryWorkItem
+	ListGoReleasesRequest
+	ListGoReleasesResponse
+	GoRelease
 */
 package apipb
 
@@ -234,6 +237,99 @@
 	return nil
 }
 
+// By default, ListGoReleases returns only the latest patches
+// of releases that are considered supported per policy.
+type ListGoReleasesRequest struct {
+}
+
+func (m *ListGoReleasesRequest) Reset()                    { *m = ListGoReleasesRequest{} }
+func (m *ListGoReleasesRequest) String() string            { return proto.CompactTextString(m) }
+func (*ListGoReleasesRequest) ProtoMessage()               {}
+func (*ListGoReleasesRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{7} }
+
+type ListGoReleasesResponse struct {
+	// Releases are Go releases, sorted with latest release first.
+	Releases []*GoRelease `protobuf:"bytes,1,rep,name=releases" json:"releases,omitempty"`
+}
+
+func (m *ListGoReleasesResponse) Reset()                    { *m = ListGoReleasesResponse{} }
+func (m *ListGoReleasesResponse) String() string            { return proto.CompactTextString(m) }
+func (*ListGoReleasesResponse) ProtoMessage()               {}
+func (*ListGoReleasesResponse) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{8} }
+
+func (m *ListGoReleasesResponse) GetReleases() []*GoRelease {
+	if m != nil {
+		return m.Releases
+	}
+	return nil
+}
+
+type GoRelease struct {
+	Major     int32  `protobuf:"varint,1,opt,name=major" json:"major,omitempty"`
+	Minor     int32  `protobuf:"varint,2,opt,name=minor" json:"minor,omitempty"`
+	Patch     int32  `protobuf:"varint,3,opt,name=patch" json:"patch,omitempty"`
+	TagName   string `protobuf:"bytes,4,opt,name=tag_name,json=tagName" json:"tag_name,omitempty"`
+	TagCommit string `protobuf:"bytes,5,opt,name=tag_commit,json=tagCommit" json:"tag_commit,omitempty"`
+	// Release branch information for this major-minor version pair.
+	// Empty if the corresponding release branch doesn't exist.
+	BranchName   string `protobuf:"bytes,6,opt,name=branch_name,json=branchName" json:"branch_name,omitempty"`
+	BranchCommit string `protobuf:"bytes,7,opt,name=branch_commit,json=branchCommit" json:"branch_commit,omitempty"`
+}
+
+func (m *GoRelease) Reset()                    { *m = GoRelease{} }
+func (m *GoRelease) String() string            { return proto.CompactTextString(m) }
+func (*GoRelease) ProtoMessage()               {}
+func (*GoRelease) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{9} }
+
+func (m *GoRelease) GetMajor() int32 {
+	if m != nil {
+		return m.Major
+	}
+	return 0
+}
+
+func (m *GoRelease) GetMinor() int32 {
+	if m != nil {
+		return m.Minor
+	}
+	return 0
+}
+
+func (m *GoRelease) GetPatch() int32 {
+	if m != nil {
+		return m.Patch
+	}
+	return 0
+}
+
+func (m *GoRelease) GetTagName() string {
+	if m != nil {
+		return m.TagName
+	}
+	return ""
+}
+
+func (m *GoRelease) GetTagCommit() string {
+	if m != nil {
+		return m.TagCommit
+	}
+	return ""
+}
+
+func (m *GoRelease) GetBranchName() string {
+	if m != nil {
+		return m.BranchName
+	}
+	return ""
+}
+
+func (m *GoRelease) GetBranchCommit() string {
+	if m != nil {
+		return m.BranchCommit
+	}
+	return ""
+}
+
 func init() {
 	proto.RegisterType((*HasAncestorRequest)(nil), "apipb.HasAncestorRequest")
 	proto.RegisterType((*HasAncestorResponse)(nil), "apipb.HasAncestorResponse")
@@ -242,6 +338,9 @@
 	proto.RegisterType((*GoFindTryWorkRequest)(nil), "apipb.GoFindTryWorkRequest")
 	proto.RegisterType((*GoFindTryWorkResponse)(nil), "apipb.GoFindTryWorkResponse")
 	proto.RegisterType((*GerritTryWorkItem)(nil), "apipb.GerritTryWorkItem")
+	proto.RegisterType((*ListGoReleasesRequest)(nil), "apipb.ListGoReleasesRequest")
+	proto.RegisterType((*ListGoReleasesResponse)(nil), "apipb.ListGoReleasesResponse")
+	proto.RegisterType((*GoRelease)(nil), "apipb.GoRelease")
 }
 
 // Reference imports to suppress errors if they are not otherwise used.
@@ -262,6 +361,9 @@
 	GetRef(ctx context.Context, in *GetRefRequest, opts ...grpc.CallOption) (*GetRefResponse, error)
 	// GoFindTryWork finds trybot work for the coordinator to build & test.
 	GoFindTryWork(ctx context.Context, in *GoFindTryWorkRequest, opts ...grpc.CallOption) (*GoFindTryWorkResponse, error)
+	// ListGoReleases lists Go releases. A release is considered to exist
+	// if a tag for it exists.
+	ListGoReleases(ctx context.Context, in *ListGoReleasesRequest, opts ...grpc.CallOption) (*ListGoReleasesResponse, error)
 }
 
 type maintnerServiceClient struct {
@@ -299,6 +401,15 @@
 	return out, nil
 }
 
+func (c *maintnerServiceClient) ListGoReleases(ctx context.Context, in *ListGoReleasesRequest, opts ...grpc.CallOption) (*ListGoReleasesResponse, error) {
+	out := new(ListGoReleasesResponse)
+	err := grpc.Invoke(ctx, "/apipb.MaintnerService/ListGoReleases", in, out, c.cc, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
 // Server API for MaintnerService service
 
 type MaintnerServiceServer interface {
@@ -309,6 +420,9 @@
 	GetRef(context.Context, *GetRefRequest) (*GetRefResponse, error)
 	// GoFindTryWork finds trybot work for the coordinator to build & test.
 	GoFindTryWork(context.Context, *GoFindTryWorkRequest) (*GoFindTryWorkResponse, error)
+	// ListGoReleases lists Go releases. A release is considered to exist
+	// if a tag for it exists.
+	ListGoReleases(context.Context, *ListGoReleasesRequest) (*ListGoReleasesResponse, error)
 }
 
 func RegisterMaintnerServiceServer(s *grpc.Server, srv MaintnerServiceServer) {
@@ -369,6 +483,24 @@
 	return interceptor(ctx, in, info, handler)
 }
 
+func _MaintnerService_ListGoReleases_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListGoReleasesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(MaintnerServiceServer).ListGoReleases(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/apipb.MaintnerService/ListGoReleases",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(MaintnerServiceServer).ListGoReleases(ctx, req.(*ListGoReleasesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
 var _MaintnerService_serviceDesc = grpc.ServiceDesc{
 	ServiceName: "apipb.MaintnerService",
 	HandlerType: (*MaintnerServiceServer)(nil),
@@ -385,6 +517,10 @@
 			MethodName: "GoFindTryWork",
 			Handler:    _MaintnerService_GoFindTryWork_Handler,
 		},
+		{
+			MethodName: "ListGoReleases",
+			Handler:    _MaintnerService_ListGoReleases_Handler,
+		},
 	},
 	Streams:  []grpc.StreamDesc{},
 	Metadata: "api.proto",
@@ -393,33 +529,42 @@
 func init() { proto.RegisterFile("api.proto", fileDescriptor0) }
 
 var fileDescriptor0 = []byte{
-	// 445 bytes of a gzipped FileDescriptorProto
-	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x53, 0x51, 0x6f, 0xd3, 0x30,
-	0x10, 0x56, 0x57, 0xda, 0xb5, 0xd7, 0x75, 0x80, 0xe9, 0x50, 0x48, 0x91, 0x18, 0x41, 0xa0, 0x3d,
-	0xf5, 0xa1, 0x08, 0xf1, 0x0c, 0x43, 0x74, 0x03, 0x21, 0xa1, 0x0c, 0x89, 0xc7, 0xc8, 0x4d, 0xaf,
-	0xa9, 0x19, 0xb1, 0x83, 0xed, 0x6e, 0xe2, 0x7f, 0xf1, 0x6b, 0xf8, 0x35, 0x28, 0xf6, 0xb9, 0xa4,
-	0x6b, 0xdf, 0x72, 0xdf, 0x7d, 0xe7, 0xfb, 0xee, 0xbe, 0x0b, 0xf4, 0x79, 0x25, 0x26, 0x95, 0x56,
-	0x56, 0xb1, 0x0e, 0xaf, 0x44, 0x35, 0x4f, 0x2e, 0x80, 0x5d, 0x70, 0xf3, 0x4e, 0xe6, 0x68, 0xac,
-	0xd2, 0x29, 0xfe, 0x5a, 0xa3, 0xb1, 0xec, 0x31, 0x74, 0x73, 0x55, 0x96, 0xc2, 0x46, 0xad, 0xd3,
-	0xd6, 0x59, 0x3f, 0xa5, 0x88, 0xc5, 0xd0, 0xe3, 0x44, 0x8d, 0x0e, 0x5c, 0x66, 0x13, 0x27, 0x19,
-	0x3c, 0xda, 0x7a, 0xc9, 0x54, 0x4a, 0x1a, 0x64, 0xcf, 0xe1, 0x68, 0xc5, 0x4d, 0xb6, 0x29, 0xab,
-	0x1f, 0xec, 0xa5, 0x83, 0xd5, 0x7f, 0x2a, 0x7b, 0x09, 0xc7, 0x6b, 0x79, 0x2d, 0xd5, 0xad, 0xcc,
-	0xa8, 0xeb, 0x81, 0x23, 0x0d, 0x09, 0x3d, 0x77, 0x60, 0x52, 0xc2, 0x70, 0x86, 0x36, 0xc5, 0x65,
-	0x50, 0xf9, 0x00, 0xda, 0x1a, 0x97, 0x24, 0xb1, 0xfe, 0x64, 0x2f, 0x60, 0x58, 0xa0, 0xd6, 0xc2,
-	0x66, 0x06, 0xf5, 0x0d, 0x06, 0x91, 0x47, 0x1e, 0xbc, 0x72, 0x58, 0xdd, 0x8e, 0x48, 0x95, 0x56,
-	0x3f, 0x30, 0xb7, 0x51, 0xdb, 0xb1, 0xa8, 0xf4, 0xab, 0x07, 0x93, 0x57, 0x70, 0x1c, 0xda, 0xd1,
-	0x28, 0x23, 0xe8, 0xdc, 0xf0, 0x9f, 0x6b, 0xa4, 0x8e, 0x3e, 0x48, 0xde, 0xc2, 0x68, 0xa6, 0x3e,
-	0x0a, 0xb9, 0xf8, 0xa6, 0x7f, 0x7f, 0x57, 0xfa, 0x3a, 0xa8, 0x7b, 0x06, 0x83, 0xa5, 0xd2, 0x99,
-	0xb1, 0xbc, 0x10, 0xb2, 0xa0, 0xb9, 0x61, 0xa9, 0xf4, 0x95, 0x47, 0x92, 0xcf, 0x70, 0x72, 0xa7,
-	0x90, 0xfa, 0x4c, 0xe1, 0xf0, 0x96, 0x0b, 0xeb, 0xab, 0xda, 0x67, 0x83, 0x69, 0x34, 0x71, 0x66,
-	0x4d, 0x66, 0x4e, 0x20, 0xd1, 0x2f, 0x2d, 0x96, 0x69, 0x20, 0x26, 0x7f, 0x5a, 0xf0, 0x70, 0x27,
-	0xcd, 0x22, 0x38, 0x0c, 0x33, 0x7a, 0xcd, 0x21, 0xac, 0x1d, 0x9e, 0x6b, 0x2e, 0xf3, 0x15, 0xad,
-	0x88, 0x22, 0x36, 0x86, 0x7e, 0xbe, 0xe2, 0xb2, 0xc0, 0x4c, 0x2c, 0x68, 0x2f, 0x3d, 0x0f, 0x5c,
-	0x2e, 0x1a, 0x67, 0x71, 0x6f, 0xeb, 0x2c, 0xc6, 0xd0, 0x2f, 0x54, 0xf0, 0xae, 0x73, 0xda, 0xae,
-	0x8b, 0x0a, 0x75, 0xde, 0x4c, 0x52, 0xb3, 0x6e, 0x48, 0xbe, 0x77, 0xf1, 0xf4, 0x6f, 0x0b, 0xee,
-	0x7f, 0xe1, 0x42, 0x5a, 0x89, 0xba, 0xb6, 0x47, 0xe4, 0xc8, 0x3e, 0xc0, 0xa0, 0x71, 0x48, 0xec,
-	0x09, 0x0d, 0xbf, 0x7b, 0xa6, 0x71, 0xbc, 0x2f, 0x45, 0x4b, 0x7c, 0x03, 0x5d, 0x6f, 0x1f, 0x1b,
-	0x6d, 0xb6, 0xd7, 0x38, 0x9e, 0xf8, 0xe4, 0x0e, 0x4a, 0x65, 0x9f, 0x60, 0xb8, 0x65, 0x0a, 0x1b,
-	0x07, 0xde, 0x1e, 0x8f, 0xe3, 0xa7, 0xfb, 0x93, 0xfe, 0xad, 0x79, 0xd7, 0xfd, 0x69, 0xaf, 0xff,
-	0x05, 0x00, 0x00, 0xff, 0xff, 0xbb, 0x60, 0x62, 0xc0, 0x76, 0x03, 0x00, 0x00,
+	// 587 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x74, 0x54, 0xcd, 0x6e, 0x13, 0x3d,
+	0x14, 0x55, 0x9a, 0x2f, 0x3f, 0x73, 0xd3, 0xf4, 0x2b, 0xa6, 0x2d, 0xe9, 0x94, 0xaa, 0x65, 0x2a,
+	0x50, 0x17, 0x28, 0x8b, 0x20, 0xc4, 0x1a, 0x8a, 0x9a, 0x16, 0x28, 0x42, 0x53, 0x24, 0x96, 0x23,
+	0x67, 0xe2, 0x4c, 0xdc, 0x76, 0xec, 0xc1, 0xe3, 0xb4, 0xe2, 0x91, 0xd8, 0xf3, 0x1a, 0xbc, 0x13,
+	0xb2, 0x7d, 0x3d, 0xe4, 0x8f, 0x5d, 0xee, 0x39, 0xe7, 0xfe, 0xcc, 0xb9, 0xd7, 0x81, 0x80, 0x16,
+	0xbc, 0x5f, 0x28, 0xa9, 0x25, 0x69, 0xd0, 0x82, 0x17, 0xa3, 0xe8, 0x02, 0xc8, 0x05, 0x2d, 0xdf,
+	0x8a, 0x94, 0x95, 0x5a, 0xaa, 0x98, 0x7d, 0x9f, 0xb1, 0x52, 0x93, 0x3d, 0x68, 0xa6, 0x32, 0xcf,
+	0xb9, 0xee, 0xd5, 0x8e, 0x6b, 0xa7, 0x41, 0x8c, 0x11, 0x09, 0xa1, 0x4d, 0x51, 0xda, 0xdb, 0xb0,
+	0x4c, 0x15, 0x47, 0x09, 0x3c, 0x5e, 0xa8, 0x54, 0x16, 0x52, 0x94, 0x8c, 0x3c, 0x83, 0xcd, 0x29,
+	0x2d, 0x93, 0x2a, 0xcd, 0x14, 0x6c, 0xc7, 0x9d, 0xe9, 0x5f, 0x29, 0x79, 0x0e, 0x5b, 0x33, 0x71,
+	0x2b, 0xe4, 0x83, 0x48, 0xb0, 0xeb, 0x86, 0x15, 0x75, 0x11, 0x3d, 0xb3, 0x60, 0x94, 0x43, 0x77,
+	0xc8, 0x74, 0xcc, 0x26, 0x7e, 0xca, 0x6d, 0xa8, 0x2b, 0x36, 0xc1, 0x11, 0xcd, 0x4f, 0x72, 0x02,
+	0xdd, 0x8c, 0x29, 0xc5, 0x75, 0x52, 0x32, 0x75, 0xcf, 0xfc, 0x90, 0x9b, 0x0e, 0xbc, 0xb6, 0x98,
+	0x69, 0x87, 0xa2, 0x42, 0xc9, 0x1b, 0x96, 0xea, 0x5e, 0xdd, 0xaa, 0x30, 0xf5, 0x8b, 0x03, 0xa3,
+	0x17, 0xb0, 0xe5, 0xdb, 0xe1, 0xa7, 0xec, 0x40, 0xe3, 0x9e, 0xde, 0xcd, 0x18, 0x76, 0x74, 0x41,
+	0xf4, 0x06, 0x76, 0x86, 0xf2, 0x9c, 0x8b, 0xf1, 0x57, 0xf5, 0xe3, 0x9b, 0x54, 0xb7, 0x7e, 0xba,
+	0x23, 0xe8, 0x4c, 0xa4, 0x4a, 0x4a, 0x4d, 0x33, 0x2e, 0x32, 0xfc, 0x6e, 0x98, 0x48, 0x75, 0xed,
+	0x90, 0xe8, 0x23, 0xec, 0x2e, 0x25, 0x62, 0x9f, 0x01, 0xb4, 0x1e, 0x28, 0xd7, 0x2e, 0xab, 0x7e,
+	0xda, 0x19, 0xf4, 0xfa, 0x76, 0x59, 0xfd, 0xa1, 0x1d, 0x10, 0xe5, 0x97, 0x9a, 0xe5, 0xb1, 0x17,
+	0x46, 0xbf, 0x6a, 0xf0, 0x68, 0x85, 0x26, 0x3d, 0x68, 0xf9, 0x6f, 0x74, 0x33, 0xfb, 0xd0, 0x6c,
+	0x78, 0xa4, 0xa8, 0x48, 0xa7, 0x68, 0x11, 0x46, 0xe4, 0x00, 0x82, 0x74, 0x4a, 0x45, 0xc6, 0x12,
+	0x3e, 0x46, 0x5f, 0xda, 0x0e, 0xb8, 0x1c, 0xcf, 0x9d, 0xc5, 0x7f, 0x0b, 0x67, 0x71, 0x00, 0x41,
+	0x26, 0xfd, 0xee, 0x1a, 0xc7, 0x75, 0x93, 0x94, 0xc9, 0xb3, 0x79, 0x12, 0x9b, 0x35, 0x3d, 0xf9,
+	0xce, 0xc6, 0xd1, 0x13, 0xd8, 0xfd, 0xc4, 0x4b, 0x3d, 0x94, 0x31, 0xbb, 0x63, 0xb4, 0x64, 0x25,
+	0xba, 0x17, 0x9d, 0xc3, 0xde, 0x32, 0x81, 0xee, 0xbc, 0x84, 0xb6, 0x42, 0x0c, 0xed, 0xd9, 0xf6,
+	0xf6, 0x78, 0x71, 0x5c, 0x29, 0xa2, 0xdf, 0x35, 0x08, 0x2a, 0xdc, 0x6c, 0x30, 0xa7, 0x37, 0x78,
+	0x85, 0x8d, 0xd8, 0x05, 0x16, 0xe5, 0x02, 0x4f, 0xda, 0xa0, 0x26, 0x30, 0x68, 0x41, 0x75, 0x3a,
+	0xb5, 0x2e, 0x34, 0x62, 0x17, 0x90, 0x7d, 0x68, 0x6b, 0x9a, 0x25, 0x82, 0xe6, 0x0c, 0x4d, 0x68,
+	0x69, 0x9a, 0x7d, 0xa6, 0x39, 0x23, 0x87, 0x00, 0x86, 0xaa, 0x6c, 0x30, 0x64, 0xa0, 0x69, 0x86,
+	0x3e, 0x1c, 0x41, 0xc7, 0x99, 0xe0, 0x92, 0x9b, 0x96, 0x07, 0x07, 0xd9, 0xfc, 0x13, 0xe8, 0xa2,
+	0x00, 0x4b, 0xb4, 0xdc, 0xf1, 0x3a, 0xd0, 0x55, 0x19, 0xfc, 0xdc, 0x80, 0xff, 0xaf, 0x28, 0x17,
+	0x5a, 0x30, 0x65, 0xee, 0x99, 0xa7, 0x8c, 0xbc, 0x87, 0xce, 0xdc, 0xcb, 0x23, 0xfb, 0x68, 0xc7,
+	0xea, 0xbb, 0x0e, 0xc3, 0x75, 0x14, 0xfa, 0xfa, 0x1a, 0x9a, 0xee, 0xde, 0xc9, 0x4e, 0x75, 0x6e,
+	0x73, 0xaf, 0x2d, 0xdc, 0x5d, 0x42, 0x31, 0xed, 0x03, 0x74, 0x17, 0xae, 0x98, 0x1c, 0x54, 0xdb,
+	0x58, 0x7d, 0x14, 0xe1, 0xd3, 0xf5, 0x24, 0xd6, 0xba, 0x82, 0xad, 0xc5, 0xa5, 0x13, 0xaf, 0x5f,
+	0x7b, 0x24, 0xe1, 0xe1, 0x3f, 0x58, 0x57, 0x6e, 0xd4, 0xb4, 0xff, 0x74, 0xaf, 0xfe, 0x04, 0x00,
+	0x00, 0xff, 0xff, 0x13, 0xc3, 0xc8, 0xa7, 0xf6, 0x04, 0x00, 0x00,
 }
diff --git a/maintner/maintnerd/apipb/api.proto b/maintner/maintnerd/apipb/api.proto
index 40b9b6f..f830345 100644
--- a/maintner/maintnerd/apipb/api.proto
+++ b/maintner/maintnerd/apipb/api.proto
@@ -62,6 +62,28 @@
   repeated string go_branch = 6;  // "master", "release-branch.go1.8", etc
 }
 
+// By default, ListGoReleases returns only the latest patches
+// of releases that are considered supported per policy.
+message ListGoReleasesRequest {}
+
+message ListGoReleasesResponse {
+  // Releases are Go releases, sorted with latest release first.
+  repeated GoRelease releases = 1;
+}
+
+message GoRelease {
+  int32 major = 1;
+  int32 minor = 2;
+  int32 patch = 3;
+  string tag_name = 4;       // "go1.11.1", etc.
+  string tag_commit = 5;     // "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac"
+
+  // Release branch information for this major-minor version pair.
+  // Empty if the corresponding release branch doesn't exist.
+  string branch_name = 6;    // "release-branch.go1.11", etc.
+  string branch_commit = 7;  // most recent commit on the release branch, e.g., "edb6c16b9b62ed8586d2e3e422911d646095b7e5"
+}
+
 service MaintnerService {
   // HasAncestor reports whether one commit contains another commit
   // in its git history.
@@ -74,4 +96,8 @@
 
   // GoFindTryWork finds trybot work for the coordinator to build & test.
   rpc GoFindTryWork(GoFindTryWorkRequest) returns (GoFindTryWorkResponse);
+
+  // ListGoReleases lists Go releases. A release is considered to exist
+  // if a tag for it exists.
+  rpc ListGoReleases(ListGoReleasesRequest) returns (ListGoReleasesResponse);
 }
diff --git a/maintner/maintnerd/maintapi/api.go b/maintner/maintnerd/maintapi/api.go
index 7250961..80139ac 100644
--- a/maintner/maintnerd/maintapi/api.go
+++ b/maintner/maintnerd/maintapi/api.go
@@ -8,6 +8,8 @@
 import (
 	"context"
 	"errors"
+	"fmt"
+	"io"
 	"log"
 	"sort"
 	"strings"
@@ -211,3 +213,127 @@
 
 	return res, nil
 }
+
+// ListGoReleases lists Go releases. A release is considered to exist
+// if a tag for it exists.
+func (s apiService) ListGoReleases(ctx context.Context, req *apipb.ListGoReleasesRequest) (*apipb.ListGoReleasesResponse, error) {
+	s.c.RLock()
+	defer s.c.RUnlock()
+	goProj := s.c.Gerrit().Project("go.googlesource.com", "go")
+	releases, err := supportedGoReleases(goProj)
+	if err != nil {
+		return nil, err
+	}
+	return &apipb.ListGoReleasesResponse{
+		Releases: releases,
+	}, nil
+}
+
+// nonChangeRefLister is implemented by *maintner.GerritProject,
+// or something that acts like it for testing.
+type nonChangeRefLister interface {
+	// ForeachNonChangeRef calls fn for each git ref on the server that is
+	// not a change (code review) ref. In general, these correspond to
+	// submitted changes. fn is called serially with sorted ref names.
+	// Iteration stops with the first non-nil error returned by fn.
+	ForeachNonChangeRef(fn func(ref string, hash maintner.GitHash) error) error
+}
+
+// supportedGoReleases returns the latest patches of releases
+// that are considered supported per policy.
+func supportedGoReleases(goProj nonChangeRefLister) ([]*apipb.GoRelease, error) {
+	type majorMinor struct {
+		Major, Minor int32
+	}
+	type tag struct {
+		Patch  int32
+		Name   string
+		Commit maintner.GitHash
+	}
+	type branch struct {
+		Name   string
+		Commit maintner.GitHash
+	}
+	tags := make(map[majorMinor]tag)
+	branches := make(map[majorMinor]branch)
+
+	// Iterate over Go tags and release branches. Find the latest patch
+	// for each major-minor pair, and fill in the appropriate fields.
+	err := goProj.ForeachNonChangeRef(func(ref string, hash maintner.GitHash) error {
+		switch {
+		case strings.HasPrefix(ref, "refs/tags/go"):
+			// Tag.
+			tagName := ref[len("refs/tags/"):]
+			var major, minor, patch int32
+			_, err := fmt.Sscanf(tagName, "go%d.%d.%d", &major, &minor, &patch)
+			if err == io.ErrUnexpectedEOF {
+				// Do nothing.
+			} else if err != nil {
+				return nil
+			}
+			if t, ok := tags[majorMinor{major, minor}]; ok && patch <= t.Patch {
+				// This patch version is not newer than what we've already seen, skip it.
+				return nil
+			}
+			tags[majorMinor{major, minor}] = tag{
+				Patch:  patch,
+				Name:   tagName,
+				Commit: hash,
+			}
+
+		case strings.HasPrefix(ref, "refs/heads/release-branch.go"):
+			// Release branch.
+			branchName := ref[len("refs/heads/"):]
+			var major, minor int32
+			_, err := fmt.Sscanf(branchName, "release-branch.go%d.%d", &major, &minor)
+			if err == io.ErrUnexpectedEOF {
+				// Do nothing.
+			} else if err != nil {
+				return nil
+			}
+			branches[majorMinor{major, minor}] = branch{
+				Name:   branchName,
+				Commit: hash,
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	// Releases are considered only to exist if they've been tagged.
+	var rs []*apipb.GoRelease
+	for v, t := range tags {
+		b := branches[v]
+		rs = append(rs, &apipb.GoRelease{
+			Major:        v.Major,
+			Minor:        v.Minor,
+			Patch:        t.Patch,
+			TagName:      t.Name,
+			TagCommit:    t.Commit.String(),
+			BranchName:   b.Name,
+			BranchCommit: b.Commit.String(),
+		})
+	}
+
+	// Sort by version. Latest first.
+	sort.Slice(rs, func(i, j int) bool {
+		x1, y1, z1 := rs[i].Major, rs[i].Minor, rs[i].Patch
+		x2, y2, z2 := rs[j].Major, rs[j].Minor, rs[j].Patch
+		if x1 != x2 {
+			return x1 > x2
+		}
+		if y1 != y2 {
+			return y1 > y2
+		}
+		return z1 > z2
+	})
+
+	// Per policy, only the latest two releases are considered supported.
+	if len(rs) > 2 {
+		rs = rs[:2]
+	}
+
+	return rs, nil
+}
diff --git a/maintner/maintnerd/maintapi/api_test.go b/maintner/maintnerd/maintapi/api_test.go
index 5d0ba01..461ef5c 100644
--- a/maintner/maintnerd/maintapi/api_test.go
+++ b/maintner/maintnerd/maintapi/api_test.go
@@ -6,12 +6,14 @@
 
 import (
 	"context"
+	"encoding/hex"
 	"flag"
 	"fmt"
 	"sync"
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"golang.org/x/build/maintner"
 	"golang.org/x/build/maintner/godata"
 	"golang.org/x/build/maintner/maintnerd/apipb"
@@ -163,3 +165,156 @@
 	}
 	return corpusCache
 }
+
+func TestSupportedGoReleases(t *testing.T) {
+	tests := []struct {
+		goProj nonChangeRefLister
+		want   []*apipb.GoRelease
+	}{
+		// A sample of real data from maintner.
+		{
+			goProj: gerritProject{
+				refs: []refHash{
+					{"HEAD", gitHash("5168fcf63f5001b38f9ac64ce5c5e3c2d397363d")},
+					{"refs/heads/dev.boringcrypto", gitHash("13bf5b80e8d8841a2a3c9b0d5dec65a0c8636253")},
+					{"refs/heads/dev.boringcrypto.go1.10", gitHash("2e2a04a605b6c3fc6e733810bdcd0200d8ed25a8")},
+					{"refs/heads/dev.boringcrypto.go1.11", gitHash("685dc1638240af70c86a146b0ddb86d51d64f269")},
+					{"refs/heads/dev.typealias", gitHash("8a5ef1501dee0715093e87cdc1c9b6becb81c882")},
+					{"refs/heads/master", gitHash("5168fcf63f5001b38f9ac64ce5c5e3c2d397363d")},
+					{"refs/heads/release-branch.go1", gitHash("08b97d4061dd75ceec1d44e4335183cd791c9306")},
+					{"refs/heads/release-branch.go1.1", gitHash("1d6d8fca241bb611af51e265c1b5a2e9ae904702")},
+					{"refs/heads/release-branch.go1.10", gitHash("e97b7d68f107ff60152f5bd5701e0286f221ee93")},
+					{"refs/heads/release-branch.go1.11", gitHash("97781d2ed116d2cd9cb870d0b84fc0ec598c9abc")},
+					{"refs/heads/release-branch.go1.2", gitHash("43d00b0942c1c6f43993ac71e1eea48e62e22b8d")},
+					{"refs/heads/release-branch.r59", gitHash("5d9765785dff74784bbdad43f7847b6825509032")},
+					{"refs/heads/release-branch.r60", gitHash("394b383a1ee0ac3fec5e453a7dbe590d3ce6d6b0")},
+					{"refs/notes/review", gitHash("c46ab9dacb2ac618d86f1c1f719bc2de46010e86")},
+					{"refs/tags/1.10beta1.mailed", gitHash("2df74db61620771e4f878c9e1db7aeecc00808ba")},
+					{"refs/tags/andybons/blog.mailed", gitHash("707a89416af909a3af6c26df93995bc17bf9ce81")},
+					{"refs/tags/go1", gitHash("6174b5e21e73714c63061e66efdbe180e1c5491d")},
+					{"refs/tags/go1.0.1", gitHash("2fffba7fe19690e038314d17a117d6b87979c89f")},
+					{"refs/tags/go1.0.2", gitHash("cb6c6570b73a1c4d19cad94570ed277f7dae55ac")},
+					{"refs/tags/go1.0.3", gitHash("30be9b4313622c2077539e68826194cb1028c691")},
+					{"refs/tags/go1.1", gitHash("205f850ceacfc39d1e9d76a9569416284594ce8c")},
+					{"refs/tags/go1.10", gitHash("bf86aec25972f3a100c3aa58a6abcbcc35bdea49")},
+					{"refs/tags/go1.10.1", gitHash("ac7c0ee26dda18076d5f6c151d8f920b43340ae3")},
+					{"refs/tags/go1.10.2", gitHash("71bdbf431b79dff61944f22c25c7e085ccfc25d5")},
+					{"refs/tags/go1.10.3", gitHash("fe8a0d12b14108cbe2408b417afcaab722b0727c")},
+					{"refs/tags/go1.10.4", gitHash("2191fce26a7fd1cd5b4975e7bd44ab44b1d9dd78")},
+					{"refs/tags/go1.10beta1", gitHash("9ce6b5c2ed5d3d5251b9a6a0c548d5fb2c8567e8")},
+					{"refs/tags/go1.10beta2", gitHash("594668a5a96267a46282ce3007a584ec07adf705")},
+					{"refs/tags/go1.10rc1", gitHash("5348aed83e39bd1d450d92d7f627e994c2db6ebf")},
+					{"refs/tags/go1.10rc2", gitHash("20e228f2fdb44350c858de941dff4aea9f3127b8")},
+					{"refs/tags/go1.11", gitHash("41e62b8c49d21659b48a95216e3062032285250f")},
+					{"refs/tags/go1.11.1", gitHash("26957168c4c0cdcc7ca4f0b19d0eb19474d224ac")},
+					{"refs/tags/go1.11beta1", gitHash("a12c1f26e4cc602dae62ec065a237172a5b8f926")},
+					{"refs/tags/go1.11beta2", gitHash("c814ac44c0571f844718f07aa52afa47e37fb1ed")},
+					{"refs/tags/go1.11beta3", gitHash("1b870077c896379c066b41657d3c9062097a6943")},
+					{"refs/tags/go1.11rc1", gitHash("807e7f2420c683384dc9c6db498808ba1b7aab17")},
+					{"refs/tags/go1.11rc2", gitHash("02c0c32960f65d0b9c66ec840c612f5f9623dc51")},
+					{"refs/tags/go1.9.7", gitHash("7df09b4a03f9e53334672674ba7983d5e7128646")},
+					{"refs/tags/go1.9beta1", gitHash("952ecbe0a27aadd184ca3e2c342beb464d6b1653")},
+					{"refs/tags/go1.9beta2", gitHash("eab99a8d548f8ba864647ab171a44f0a5376a6b3")},
+					{"refs/tags/go1.9rc1", gitHash("65c6c88a9442b91d8b2fd0230337b1fda4bb6cdf")},
+					{"refs/tags/go1.9rc2", gitHash("048c9cfaacb6fe7ac342b0acd8ca8322b6c49508")},
+					{"refs/tags/release.r59", gitHash("5d9765785dff74784bbdad43f7847b6825509032")},
+					{"refs/tags/release.r60", gitHash("5464bfebe723752dfc09a6dd6b361b8e79db5995")},
+					{"refs/tags/release.r60.1", gitHash("4af7136fcf874e212d66c72178a68db969918b25")},
+					{"refs/tags/weekly", gitHash("3895b5051df256b442d0b0af50debfffd8d75164")},
+					{"refs/tags/weekly.2009-11-10", gitHash("78c47c36b2984058c1bec0bd72e0b127b24fcd44")},
+					{"refs/tags/weekly.2009-11-10.1", gitHash("c57054f7b49539ca4ed6533267c1c20c39aaaaa5")},
+				},
+			},
+			want: []*apipb.GoRelease{
+				{
+					Major: 1, Minor: 11, Patch: 1,
+					TagName:      "go1.11.1",
+					TagCommit:    "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+					BranchName:   "release-branch.go1.11",
+					BranchCommit: "97781d2ed116d2cd9cb870d0b84fc0ec598c9abc",
+				},
+				{
+					Major: 1, Minor: 10, Patch: 4,
+					TagName:      "go1.10.4",
+					TagCommit:    "2191fce26a7fd1cd5b4975e7bd44ab44b1d9dd78",
+					BranchName:   "release-branch.go1.10",
+					BranchCommit: "e97b7d68f107ff60152f5bd5701e0286f221ee93",
+				},
+			},
+		},
+
+		// Detect and handle a new major version.
+		{
+			goProj: gerritProject{
+				refs: []refHash{
+					{"refs/tags/go1.5", gitHash("9b82ca331d1fa30e3428e7914ba780ae7f75a702")},
+					{"refs/tags/go1.42.1", gitHash("23982c09ae5ac811d1dd0099e1626596ade61000")},
+					{"refs/tags/go1", gitHash("5c503fde0aa534d3259533802052f936c95fa782")},
+					{"refs/tags/go2", gitHash("43126518de2eb0dadc0917a593f08637318986bf")},
+					{"refs/tags/go1.11.111", gitHash("c59f000d9bb66592ff84a942014afd1a7be4c953")}, // The onesiest release ever!
+					{"refs/heads/release-branch.go1", gitHash("b0f2d801c19fc8798ecf67e50364a44dba606fcd")},
+					{"refs/heads/release-branch.go1.5", gitHash("a6ae58c93408bcc17758d397eed0ace973de8481")},
+					{"refs/heads/release-branch.go1.11", gitHash("f4f148ef7962271ff8ffcebf13400ded535e9957")},
+					{"refs/heads/release-branch.go1.42", gitHash("362986e7a4b5edc911ed55324c37106c40abe3fb")},
+					{"refs/heads/release-branch.go2", gitHash("cfbe0f14bcbf1e773f8dd9a968c80cf0b9238c59")},
+					{"refs/heads/release-branch.go1.2", gitHash("6523e1eb33ef792df04e08462ed332b95311261e")},
+				},
+			},
+			want: []*apipb.GoRelease{
+				{
+					Major: 2, Minor: 0, Patch: 0,
+					TagName:      "go2",
+					TagCommit:    "43126518de2eb0dadc0917a593f08637318986bf",
+					BranchName:   "release-branch.go2",
+					BranchCommit: "cfbe0f14bcbf1e773f8dd9a968c80cf0b9238c59",
+				},
+				{
+					Major: 1, Minor: 42, Patch: 1,
+					TagName:      "go1.42.1",
+					TagCommit:    "23982c09ae5ac811d1dd0099e1626596ade61000",
+					BranchName:   "release-branch.go1.42",
+					BranchCommit: "362986e7a4b5edc911ed55324c37106c40abe3fb",
+				},
+			},
+		},
+	}
+	for i, tt := range tests {
+		got, err := supportedGoReleases(tt.goProj)
+		if err != nil {
+			t.Fatalf("%d: supportedGoReleases: %v", i, err)
+		}
+		if diff := cmp.Diff(got, tt.want); diff != "" {
+			t.Errorf("%d: supportedGoReleases: (-got +want)\n%s", i, diff)
+		}
+	}
+}
+
+type gerritProject struct {
+	refs []refHash
+}
+
+func (gp gerritProject) ForeachNonChangeRef(fn func(ref string, hash maintner.GitHash) error) error {
+	for _, r := range gp.refs {
+		err := fn(r.Ref, r.Hash)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+type refHash struct {
+	Ref  string
+	Hash maintner.GitHash
+}
+
+func gitHash(hexa string) maintner.GitHash {
+	if len(hexa) != 40 {
+		panic(fmt.Errorf("bogus git hash %q", hexa))
+	}
+	binary, err := hex.DecodeString(hexa)
+	if err != nil {
+		panic(fmt.Errorf("bogus git hash %q: %v", hexa, err))
+	}
+	return maintner.GitHash(binary)
+}
diff --git a/maintner/maintq/maintq.go b/maintner/maintq/maintq.go
index 344084d..7cf7a3a 100644
--- a/maintner/maintq/maintq.go
+++ b/maintner/maintq/maintq.go
@@ -50,9 +50,10 @@
 	mc = apipb.NewMaintnerServiceClient(cc)
 
 	cmdFunc := map[string]func(args []string) error{
-		"has-ancestor": callHasAncestor,
-		"get-ref":      callGetRef,
-		"try-work":     callTryWork,
+		"has-ancestor":  callHasAncestor,
+		"get-ref":       callGetRef,
+		"try-work":      callTryWork,
+		"list-releases": callListReleases,
 	}
 	log.SetFlags(0)
 	if flag.NArg() == 0 || cmdFunc[flag.Arg(0)] == nil {
@@ -61,7 +62,7 @@
 			cmds = append(cmds, cmd)
 		}
 		sort.Strings(cmds)
-		log.Fatalf(`Usage: maintq %q ...`, cmds)
+		log.Fatalf(`Usage: maintq %v ...`, cmds)
 	}
 	if err := cmdFunc[flag.Arg(0)](flag.Args()[1:]); err != nil {
 		log.Fatal(err)
@@ -114,3 +115,17 @@
 	fmt.Println(res)
 	return nil
 }
+
+func callListReleases(args []string) error {
+	if len(args) != 0 {
+		return errors.New("Usage: maintq list-releases")
+	}
+	res, err := mc.ListGoReleases(ctx, &apipb.ListGoReleasesRequest{})
+	if err != nil {
+		return err
+	}
+	for _, r := range res.Releases {
+		fmt.Println(r)
+	}
+	return nil
+}