diff --git a/cmd/relui/README.md b/cmd/relui/README.md
new file mode 100644
index 0000000000..eb1b5114f4
--- /dev/null
+++ b/cmd/relui/README.md
@@ -0,0 +1,12 @@
+#golang.org/x/build/cmd/relui
+
+```
+ ▀▀█ ▀
+ ▄ ▄▄ ▄▄▄ █ ▄ ▄ ▄▄▄
+ █▀ ▀ █▀ █ █ █ █ █
+ █ █▀▀▀▀ █ █ █ █
+ █ ▀█▄▄▀ ▀▄▄ ▀▄▄▀█ ▄▄█▄▄
+```
+
+relui is a web interface for managing the release process of Go.
+
diff --git a/cmd/relui/index.html b/cmd/relui/index.html
new file mode 100644
index 0000000000..0c7de905eb
--- /dev/null
+++ b/cmd/relui/index.html
@@ -0,0 +1,35 @@
+
+
+
+
Go Releases
+
+
+
+
+
+
+ Workflows
+
+ -
+
Local Release - 20a838ab
+
+
+ -
+
Fetch blob - 20a838ab
+ Status: created
+
+
+
+
+
+
+
+
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
new file mode 100644
index 0000000000..57e52bb79d
--- /dev/null
+++ b/cmd/relui/main.go
@@ -0,0 +1,22 @@
+// 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 (
+ "log"
+ "net/http"
+ "os"
+)
+
+func main() {
+ http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(homeHandler)))
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+
+ log.Printf("Listening on :" + port)
+ log.Fatal(http.ListenAndServe(":"+port, http.DefaultServeMux))
+}
diff --git a/cmd/relui/static/styles.css b/cmd/relui/static/styles.css
new file mode 100644
index 0000000000..3d6217a3b9
--- /dev/null
+++ b/cmd/relui/static/styles.css
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+*,
+:before,
+:after {
+ box-sizing: border-box;
+}
+html,
+.Site {
+ height: 100%;
+}
+.Site {
+ display: flex;
+ flex-direction: column;
+ font-family: sans-serif;
+ margin: 0;
+}
+h1,
+h2 {
+ font-weight: 600;
+ letter-spacing: 0.03rem;
+}
+
+h3,
+h4 {
+ font-weight: 600;
+ letter-spacing: 0.08rem;
+}
+h5,
+h6 {
+ font-weight: 500;
+ letter-spacing: 0.08rem;
+}
+.Site-content {
+ flex: 1 0 auto;
+ padding: 0.625rem;
+ width: 100%;
+}
+.Site-header {
+ flex: none;
+}
+.Header {
+ background: #e0ebf5;
+ color: #375eab;
+ padding: 0.625rem;
+}
+.Header-title {
+ font-size: 1.5rem;
+ margin: 0;
+}
diff --git a/cmd/relui/testing/test.css b/cmd/relui/testing/test.css
new file mode 100644
index 0000000000..ecd36b5e48
--- /dev/null
+++ b/cmd/relui/testing/test.css
@@ -0,0 +1 @@
+.Header { font-size: 10rem; }
diff --git a/cmd/relui/web.go b/cmd/relui/web.go
new file mode 100644
index 0000000000..40cd773bf8
--- /dev/null
+++ b/cmd/relui/web.go
@@ -0,0 +1,54 @@
+// 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 (
+ "html/template"
+ "log"
+ "mime"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+)
+
+func fileServerHandler(root string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ if _, err := os.Stat(path.Join(root, r.URL.Path)); os.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path)))
+ w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
+
+ fs := http.FileServer(http.Dir(root))
+ fs.ServeHTTP(w, r)
+ })
+}
+
+var homeTemplate = template.Must(template.ParseFiles(relativeFile("index.html")))
+
+func homeHandler(w http.ResponseWriter, _ *http.Request) {
+ if err := homeTemplate.Execute(w, nil); err != nil {
+ log.Printf("homeHandlerFunc: %v", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+}
+
+// relativeFile returns the path to the provided file or directory,
+// conditionally prepending a relative path depending on the environment.
+//
+// In tests the current directory is ".", but the command may be running from the module root.
+func relativeFile(base string) string {
+ // Check to see if it is in "." first.
+ if _, err := os.Stat(base); err == nil {
+ return base
+ }
+ return filepath.Join("cmd/relui", base)
+}
diff --git a/cmd/relui/web_test.go b/cmd/relui/web_test.go
new file mode 100644
index 0000000000..1649e2d13f
--- /dev/null
+++ b/cmd/relui/web_test.go
@@ -0,0 +1,87 @@
+// 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"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestHomeHandler(t *testing.T) {
+ req := httptest.NewRequest("GET", "/", nil)
+ w := httptest.NewRecorder()
+
+ homeHandler(w, req)
+ resp := w.Result()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
+ }
+}
+
+func TestFileServerHandler(t *testing.T) {
+ h := fileServerHandler("./testing", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("Home"))
+ }))
+
+ cases := []struct {
+ desc string
+ path string
+ wantCode int
+ wantBody string
+ wantHeaders map[string]string
+ }{
+ {
+ desc: "fallback to next handler",
+ path: "/",
+ wantCode: http.StatusOK,
+ wantBody: "Home",
+ },
+ {
+ desc: "sets headers and returns file",
+ path: "/test.css",
+ wantCode: http.StatusOK,
+ wantBody: ".Header { font-size: 10rem; }\n",
+ wantHeaders: map[string]string{
+ "Content-Type": "text/css; charset=utf-8",
+ "Cache-Control": "no-cache, private, max-age=0",
+ },
+ },
+ {
+ desc: "handles missing file",
+ path: "/foo.js",
+ wantCode: http.StatusNotFound,
+ wantBody: "404 page not found\n",
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.desc, func(t *testing.T) {
+ req := httptest.NewRequest("GET", c.path, nil)
+ w := httptest.NewRecorder()
+
+ h.ServeHTTP(w, req)
+ resp := w.Result()
+ defer resp.Body.Close()
+
+ if resp.StatusCode != c.wantCode {
+ t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
+ }
+ b, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Errorf("resp.Body = _, %v, wanted no error", err)
+ }
+ if string(b) != c.wantBody {
+ t.Errorf("resp.Body = %q, %v, wanted %q, %v", b, err, c.wantBody, nil)
+ }
+ for k, v := range c.wantHeaders {
+ if resp.Header.Get(k) != v {
+ t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
+ }
+ }
+ })
+ }
+}