all: promote new app deployments only after they become ready

There are differences in the App Engine environment that
cannot be adequately simulated elsewhere. Although we do
the best we can with testing locally, there will always be
differences.

The old makefiles deployed the site, then ran a regression
test against it, and then promoted the tested version.

This change does the same. The testing has moved into the
web server proper so that it can test the handler directly and
thereby check things like the responses on different domains.

The go-app-deploy.sh now always deploys --no-promote,
only promoting after a self-test passes on the deployed site.

Unlike the old check which only applied to golang.org,
the new pre-promotion testing happens for all the sites.

Also factor out GoogleCN into internal/web, because we needed
to modify it (to avoid internal/webtest's requests being diagnosed
as coming from China) and there were too many copies.

Change-Id: I0cde0e2167df2332939908e716ddb6bf429f2565
Reviewed-on: https://go-review.googlesource.com/c/website/+/329250
Trust: Russ Cox <rsc@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/blog/blog.go b/blog/blog.go
index 04c4851..5535e5a 100644
--- a/blog/blog.go
+++ b/blog/blog.go
@@ -20,6 +20,7 @@
 	_ "golang.org/x/tools/playground"
 	"golang.org/x/website"
 	"golang.org/x/website/internal/backport/httpfs"
+	"golang.org/x/website/internal/webtest"
 )
 
 var (
@@ -66,6 +67,10 @@
 	if err != nil {
 		log.Fatal(err)
 	}
+
+	h = webtest.HandlerWithCheck(h, "/_readycheck",
+		filepath.Join(blogRoot, "testdata/*.txt"))
+
 	http.Handle("/", h)
 
 	ln, err := net.Listen("tcp", *httpAddr)
diff --git a/cmd/golangorg/app.yaml b/cmd/golangorg/app.yaml
index c03697d..9d6401b3 100644
--- a/cmd/golangorg/app.yaml
+++ b/cmd/golangorg/app.yaml
@@ -6,7 +6,6 @@
 main: ./cmd/golangorg
 
 env_variables:
-  GOLANGORG_CHECK_COUNTRY: true
   GOLANGORG_REQUIRE_DL_SECRET_KEY: true
   GOLANGORG_ENFORCE_HOSTS: true
   GOLANGORG_REDIS_ADDR: 10.0.0.4:6379 # instance "gophercache"
diff --git a/cmd/golangorg/server.go b/cmd/golangorg/server.go
index cedde62..3261443 100644
--- a/cmd/golangorg/server.go
+++ b/cmd/golangorg/server.go
@@ -36,6 +36,7 @@
 	"golang.org/x/website/internal/redirect"
 	"golang.org/x/website/internal/short"
 	"golang.org/x/website/internal/web"
+	"golang.org/x/website/internal/webtest"
 
 	// Registers "/compile" handler that redirects to play.golang.org/compile.
 	// If we are in prod we will register "golang.org/compile" separately,
@@ -93,6 +94,9 @@
 
 	handler := NewHandler(*contentDir, *goroot)
 
+	handler = webtest.HandlerWithCheck(handler, "/_readycheck",
+		filepath.Join(*contentDir, "../cmd/golangorg/testdata/*.txt"))
+
 	if *verbose {
 		log.Printf("golang.org server:")
 		log.Printf("\tversion = %s", runtime.Version())
@@ -137,7 +141,6 @@
 	if err != nil {
 		log.Fatalf("NewSite: %v", err)
 	}
-	site.GoogleCN = googleCN
 
 	mux := http.NewServeMux()
 	mux.Handle("/", site)
@@ -195,26 +198,6 @@
 	log.Println("AppEngine initialization complete")
 }
 
-// googleCN reports whether request r is considered
-// to be served from golang.google.cn.
-// TODO: This is duplicated within internal/proxy. Move to a common location.
-func googleCN(r *http.Request) bool {
-	if r.FormValue("googlecn") != "" {
-		return true
-	}
-	if strings.HasSuffix(r.Host, ".cn") {
-		return true
-	}
-	if !env.CheckCountry() {
-		return false
-	}
-	switch r.Header.Get("X-Appengine-Country") {
-	case "", "ZZ", "CN":
-		return true
-	}
-	return false
-}
-
 func blogHandler(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, "https://blog.golang.org"+strings.TrimPrefix(r.URL.Path, "/blog"), http.StatusFound)
 }
diff --git a/cmd/golangorg/server_test.go b/cmd/golangorg/server_test.go
index 7d2b24f..2421a05 100644
--- a/cmd/golangorg/server_test.go
+++ b/cmd/golangorg/server_test.go
@@ -5,80 +5,13 @@
 package main
 
 import (
-	"bytes"
-	"flag"
-	"fmt"
-	"io/ioutil"
-	"net"
-	"net/http"
-	"os"
-	"os/exec"
 	"path/filepath"
 	"runtime"
-	"strings"
 	"testing"
-	"time"
 
 	"golang.org/x/website/internal/webtest"
 )
 
-func serverAddress(t *testing.T) string {
-	ln, err := net.Listen("tcp", "127.0.0.1:0")
-	if err != nil {
-		ln, err = net.Listen("tcp6", "[::1]:0")
-	}
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer ln.Close()
-	return ln.Addr().String()
-}
-
-func waitForServerReady(t *testing.T, addr string) {
-	waitForServer(t,
-		fmt.Sprintf("http://%v/", addr),
-		"The Go Programming Language",
-		15*time.Second)
-}
-
-const pollInterval = 200 * time.Millisecond
-
-func waitForServer(t *testing.T, url, match string, timeout time.Duration) {
-	// "health check" duplicated from x/tools/cmd/tipgodoc/tip.go
-	deadline := time.Now().Add(timeout)
-	for time.Now().Before(deadline) {
-		time.Sleep(pollInterval)
-		res, err := http.Get(url)
-		if err != nil {
-			continue
-		}
-		rbody, err := ioutil.ReadAll(res.Body)
-		res.Body.Close()
-		if err == nil && res.StatusCode == http.StatusOK {
-			if bytes.Contains(rbody, []byte(match)) {
-				return
-			}
-		}
-	}
-	t.Fatalf("Server failed to respond in %v", timeout)
-}
-
-func killAndWait(cmd *exec.Cmd) {
-	cmd.Process.Kill()
-	cmd.Wait()
-}
-
-func init() {
-	// TestWeb reinvokes the test binary (us) with -be-main
-	// to simulate running the actual golangorg binary.
-	if len(os.Args) >= 2 && os.Args[1] == "-be-main" {
-		os.Args = os.Args[1:]
-		os.Args[0] = "(golangorg)"
-		main()
-		os.Exit(0)
-	}
-}
-
 func TestWeb(t *testing.T) {
 	h := NewHandler("../../_content", runtime.GOROOT())
 	files, err := filepath.Glob("testdata/*.txt")
@@ -91,16 +24,3 @@
 		}
 	}
 }
-
-// Regression tests to run against a production instance of golangorg.
-
-var host = flag.String("regtest.host", "", "host to run regression test against")
-
-func TestLiveServer(t *testing.T) {
-	*host = strings.TrimSuffix(*host, "/")
-	if *host == "" {
-		t.Skip("regtest.host flag missing.")
-	}
-
-	webtest.TestServer(t, "testdata/*.txt", *host)
-}
diff --git a/cmd/golangorg/testdata/live.txt b/cmd/golangorg/testdata/live.txt
index 92c5a29..f31a4c7 100644
--- a/cmd/golangorg/testdata/live.txt
+++ b/cmd/golangorg/testdata/live.txt
@@ -1,36 +1,36 @@
 # Tests that can only run against the live server,
 # because they depend on production resources.
 
-GET /dl/
+GET https://golang.org/dl/
 body contains href="/dl/go1.11.windows-amd64.msi"
 
-GET /dl/?mode=json
+GET https://golang.org/dl/?mode=json
 body contains .windows-amd64.msi
 body !contains UA-
 
-GET /s/go2design
+GET https://golang.org/s/go2design
 code == 302
 body ~ proposal.*Found
 body !contains UA-
 
-POST /compile
+POST https://golang.org/compile
 postquery
 	body=package main; func main() { print(6*7); }
 body == {"compile_errors":"","output":"42"}
 
-POST /compile
+POST https://golang.org/compile
 postquery
 	body=//empty
 body contains expected 'package', found 'EOF'
 body !contains UA-
 
-POST /compile
+POST https://golang.org/compile
 postquery
 	version=2
 	body=package main; import ("fmt"; "time"); func main() {fmt.Print("A"); time.Sleep(time.Second); fmt.Print("B")}
 body == {"Errors":"","Events":[{"Message":"A","Kind":"stdout","Delay":0},{"Message":"B","Kind":"stdout","Delay":1000000000}]}
 
-POST /share
+POST https://golang.org/share
 postbody
 	package main
 body !contains UA-
diff --git a/cmd/golangorg/testdata/release.txt b/cmd/golangorg/testdata/release.txt
index 5724f4f..fc38347 100644
--- a/cmd/golangorg/testdata/release.txt
+++ b/cmd/golangorg/testdata/release.txt
@@ -1,4 +1,4 @@
-GET /doc/devel/release
+GET https://golang.org/doc/devel/release
 header content-type == text/html; charset=utf-8
 trimbody contains
 	<h2 id="go1.14">go1.14 (released 2020-02-25)</h2>
diff --git a/cmd/golangorg/testdata/web.txt b/cmd/golangorg/testdata/web.txt
index 3279d8e..676e9d7 100644
--- a/cmd/golangorg/testdata/web.txt
+++ b/cmd/golangorg/testdata/web.txt
@@ -1,4 +1,4 @@
-GET /
+GET https://golang.org/
 body contains Go is an open source programming language
 body contains Binary distributions available for
 
@@ -11,143 +11,143 @@
 body contains href="/golang.org/doc
 body !contains href="/doc
 
-GET /change/75944e2e3a63
+GET https://golang.org/change/75944e2e3a63
 code == 302
 redirect contains bdb10cf
 body contains bdb10cf
 body !contains UA-
 
-GET /cmd/compile/internal/amd64/
+GET https://golang.org/cmd/compile/internal/amd64/
 body contains href="/src/cmd/compile/internal/amd64/ssa.go"
 
-GET /conduct
+GET https://golang.org/conduct
 body contains Project Stewards
 
-GET /doc/
+GET https://golang.org/doc/
 body contains an introduction to using modules in a simple project
 
-GET /doc/asm
+GET https://golang.org/doc/asm
 body ~ Quick Guide.*Assembler
 
-GET /doc/debugging_with_gdb.html
+GET https://golang.org/doc/debugging_with_gdb.html
 redirect == /doc/gdb
 
-GET /doc/devel/release
+GET https://golang.org/doc/devel/release
 body ~ go1\.14\.2\s+\(released 2020-04-08\)\s+includes\s+fixes to cgo, the go command, the runtime,
 
-GET /doc/devel/release.html
+GET https://golang.org/doc/devel/release.html
 redirect == /doc/devel/release
 
-GET /doc/faq
+GET https://golang.org/doc/faq
 body contains What is the purpose of the project
 
-GET /doc/gdb
+GET https://golang.org/doc/gdb
 body contains Debugging Go Code
 
-GET /doc/go1.16.html
+GET https://golang.org/doc/go1.16.html
 redirect == /doc/go1.16
 
-GET /doc/go1.16
+GET https://golang.org/doc/go1.16
 body contains Go 1.16
 
-GET /doc/go_spec
+GET https://golang.org/doc/go_spec
 redirect == /ref/spec
 
-GET /doc/go_spec.html
+GET https://golang.org/doc/go_spec.html
 redirect == /ref/spec
 
-GET /doc/go_spec.md
+GET https://golang.org/doc/go_spec.md
 redirect == /ref/spec
 
-GET /doc/go_mem.html
+GET https://golang.org/doc/go_mem.html
 redirect == /ref/mem
 
-GET /doc/go_mem.md
+GET https://golang.org/doc/go_mem.md
 redirect == /ref/mem
 
-GET /doc/help.html
+GET https://golang.org/doc/help.html
 redirect == /help
 
-GET /help/
+GET https://golang.org/help/
 redirect == /help
 
-GET /help
+GET https://golang.org/help
 body contains Get help
 
-GET /pkg/fmt/
+GET https://golang.org/pkg/fmt/
 body contains Package fmt implements formatted I/O
 
-GET /src/fmt/
+GET https://golang.org/src/fmt/
 body contains scan_test.go
 
-GET /src/fmt/print.go
+GET https://golang.org/src/fmt/print.go
 body contains // Println formats using
 
-GET /pkg
+GET https://golang.org/pkg
 redirect == /pkg/
 
-GET /pkg/
+GET https://golang.org/pkg/
 body contains Standard library
 body contains Package fmt implements formatted I/O
 body !contains internal/syscall
 body !contains cmd/gc
 
-GET /pkg/?m=all
+GET https://golang.org/pkg/?m=all
 body contains Standard library
 body contains Package fmt implements formatted I/O
 body contains internal/syscall/?m=all
 body !contains cmd/gc
 
-GET /pkg/bufio/
+GET https://golang.org/pkg/bufio/
 body contains href="/pkg/io/#Writer
 
-GET /pkg/database/sql/
+GET https://golang.org/pkg/database/sql/
 body contains The number of connections currently in use; added in Go 1.11
 body contains The number of idle connections; added in Go 1.11
 
-GET /cmd/compile/internal/amd64/
+GET https://golang.org/cmd/compile/internal/amd64/
 body contains href="/src/cmd/compile/internal/amd64/ssa.go"
 
-GET /pkg/math/bits/
+GET https://golang.org/pkg/math/bits/
 body contains Added in Go 1.9
 
-GET /pkg/net/
+GET https://golang.org/pkg/net/
 body contains // IPv6 scoped addressing zone; added in Go 1.1
 
-GET /pkg/net/http/
+GET https://golang.org/pkg/net/http/
 body contains title="Added in Go 1.11"
 
-GET /pkg/net/http/httptrace/
+GET https://golang.org/pkg/net/http/httptrace/
 body ~ Got1xxResponse.*// Go 1\.11
 body ~ GotFirstResponseByte func\(\)\s*$
 
-GET /pkg/os/
+GET https://golang.org/pkg/os/
 body contains func Open
 
-GET /pkg/strings/
+GET https://golang.org/pkg/strings/
 body contains href="/src/strings/strings.go"
 
-GET /project
+GET https://golang.org/project
 body contains <li><a href="/doc/go1.14">Go 1.14</a> <small>(February 2020)</small></li>
 body contains <li><a href="/doc/go1.1">Go 1.1</a> <small>(May 2013)</small></li>
 
-GET /project/
+GET https://golang.org/project/
 redirect == /project
 
-GET /project/notexist
+GET https://golang.org/project/notexist
 code == 404
 
-GET /ref/mem
+GET https://golang.org/ref/mem
 body contains Memory Model
 
-GET /ref/spec
+GET https://golang.org/ref/spec
 body contains Go Programming Language Specification
 
-GET /robots.txt
+GET https://golang.org/robots.txt
 body contains Disallow: /search
 body !contains UA-
 
-GET /x/net
+GET https://golang.org/x/net
 code == 200
 body contains <meta name="go-import" content="golang.org/x/net git https://go.googlesource.com/net">
 body !contains UA-
diff --git a/cmd/golangorg/testdata/x.txt b/cmd/golangorg/testdata/x.txt
index 4704926..2423387 100644
--- a/cmd/golangorg/testdata/x.txt
+++ b/cmd/golangorg/testdata/x.txt
@@ -1,25 +1,25 @@
-GET /x/net
+GET https://golang.org/x/net
 code == 200
 body contains <meta name="go-import" content="golang.org/x/net git https://go.googlesource.com/net">
 body contains http-equiv="refresh" content="0; url=https://pkg.go.dev/golang.org/x/net">
 
-GET /x/net/suffix
+GET https://golang.org/x/net/suffix
 code == 200
 body contains <meta name="go-import" content="golang.org/x/net git https://go.googlesource.com/net">
 body contains http-equiv="refresh" content="0; url=https://pkg.go.dev/golang.org/x/net/suffix">
 
-GET /x/pkgsite
+GET https://golang.org/x/pkgsite
 code == 200
 body contains <meta name="go-import" content="golang.org/x/pkgsite git https://go.googlesource.com/pkgsite">
 body contains <a href="https://pkg.go.dev/golang.org/x/pkgsite">Redirecting to documentation...</a>
 body contains http-equiv="refresh" content="0; url=https://pkg.go.dev/golang.org/x/pkgsite">
 
-GET /x/notexist
+GET https://golang.org/x/notexist
 code == 404
 
-GET /x/
+GET https://golang.org/x/
 code == 307
 header location == https://pkg.go.dev/search?q=golang.org/x
 
-GET /x/In%20Valid,X
+GET https://golang.org/x/In%20Valid,X
 code == 404
diff --git a/go-app-deploy.sh b/go-app-deploy.sh
index 17d036c..f3da8d1 100644
--- a/go-app-deploy.sh
+++ b/go-app-deploy.sh
@@ -6,7 +6,7 @@
 # This script is meant to be run from Cloud Build as a substitute
 # for "gcloud app deploy", as in:
 #
-#	go-app-deploy.sh app.yaml
+#	go-app-deploy.sh [--project=name] app.yaml
 #
 # It should not be run by hand and is therefore not marked executable.
 #
@@ -25,18 +25,68 @@
 
 set -e
 
+project=golang-org
+case "$1" in
+--project=*)
+	project=$(echo $1 | sed 's/--project=//')
+	shift
+esac
+
+yaml=app.yaml
+case "$1" in
+*.yaml)
+	yaml=$1
+	shift
+esac
+
+if [ $# != 0 ]; then
+	echo 'usage: go-app-deploy.sh [--project=name] path/to/app.yaml' >&2
+	exit 2
+fi
+
 promote=$(
 	git cat-file -p 'HEAD' |
 	awk '
-		BEGIN { flag = "--no-promote" }
-		/^Reviewed-on:/ { flag = "--no-promote" }
-		/^Website-Publish:/ { flag = "--promote" }
+		BEGIN { flag = "false" }
+		/^Reviewed-on:/ { flag = "false" }
+		/^Website-Publish:/ { flag = "true" }
 		END {print flag}
 	'
 )
 
-version=$(
-	git log -n1 --date='format:%Y-%m-%d-%H%M%S' --pretty='format:%cd-%h'
-)
+version=$(git log -n1 --date='format:%Y-%m-%d-%H%M%S' --pretty='format:%cd-%h')
 
-gcloud app deploy $promote -v $version "$@"
+service=$(awk '$1=="service:" {print $2}' $yaml)
+
+servicedot="-$service-dot"
+if [ "$service" = default ]; then
+	servicedot=""
+fi
+host="$version-dot$servicedot-$project.appspot.com"
+
+echo "### deploying to https://$host"
+gcloud -q --project=$project app deploy -v $version --no-promote $yaml
+
+curl --version
+
+for i in 1 2 3 4 5; do
+	if curl -s --fail --show-error "https://$host/_readycheck"; then
+		echo '### site is up!'
+		if $promote; then
+			serving=$(gcloud app services describe --project=$project $service | grep ': 1.0')
+			if [ "$serving" '>' "$version" ]; then
+				echo "### serving version $serving is newer than our $version; not promoting"
+				exit 1
+			fi
+			echo '### promoting'
+			gcloud -q --project=$project app services set-traffic $service --splits=$version=1
+		fi
+		exit 0
+	fi
+	echo '### not healthy'
+	curl "https://$host/_readycheck" # show response body
+done
+
+echo "### failed to become healthy; giving up"
+exit 1
+
diff --git a/go.dev/cmd/frontend/main.go b/go.dev/cmd/frontend/main.go
index 7f6d1ae..db67169 100644
--- a/go.dev/cmd/frontend/main.go
+++ b/go.dev/cmd/frontend/main.go
@@ -10,9 +10,11 @@
 	"net/http"
 	"net/url"
 	"os"
+	"path/filepath"
 	"strings"
 
 	"golang.org/x/website/go.dev/cmd/internal/site"
+	"golang.org/x/website/internal/webtest"
 )
 
 var discoveryHosts = map[string]string{
@@ -27,13 +29,14 @@
 		// Running in repo root.
 		dir = "go.dev"
 	}
-	godev, err := site.Load(dir)
+
+	h, err := NewHandler(dir)
 	if err != nil {
 		log.Fatal(err)
 	}
-	http.Handle("/", addCSP(http.FileServer(godev)))
-	http.Handle("/explore/", http.StripPrefix("/explore/", redirectHosts(discoveryHosts)))
-	http.Handle("learn.go.dev/", http.HandlerFunc(redirectLearn))
+
+	h = webtest.HandlerWithCheck(h, "/_readycheck",
+		filepath.Join(dir, "cmd/frontend/testdata/*.txt"))
 
 	addr := ":" + listenPort()
 	if addr == ":0" {
@@ -45,7 +48,19 @@
 	}
 	defer l.Close()
 	log.Printf("Listening on http://%v/\n", l.Addr().String())
-	log.Print(http.Serve(l, nil))
+	log.Print(http.Serve(l, h))
+}
+
+func NewHandler(dir string) (http.Handler, error) {
+	godev, err := site.Load(dir)
+	if err != nil {
+		return nil, err
+	}
+	mux := http.NewServeMux()
+	mux.Handle("/", addCSP(http.FileServer(godev)))
+	mux.Handle("/explore/", http.StripPrefix("/explore/", redirectHosts(discoveryHosts)))
+	mux.Handle("learn.go.dev/", http.HandlerFunc(redirectLearn))
+	return mux, nil
 }
 
 func redirectLearn(w http.ResponseWriter, r *http.Request) {
diff --git a/go.dev/cmd/frontend/server_test.go b/go.dev/cmd/frontend/server_test.go
new file mode 100644
index 0000000..831838a
--- /dev/null
+++ b/go.dev/cmd/frontend/server_test.go
@@ -0,0 +1,19 @@
+// Copyright 2021 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 (
+	"testing"
+
+	"golang.org/x/website/internal/webtest"
+)
+
+func TestWeb(t *testing.T) {
+	h, err := NewHandler("../..")
+	if err != nil {
+		t.Fatal(err)
+	}
+	webtest.TestHandler(t, "testdata/*.txt", h)
+}
diff --git a/go.dev/cmd/frontend/testdata/godev.txt b/go.dev/cmd/frontend/testdata/godev.txt
new file mode 100644
index 0000000..950b385
--- /dev/null
+++ b/go.dev/cmd/frontend/testdata/godev.txt
@@ -0,0 +1,5 @@
+GET https://go.dev/
+body contains <h2 class="WhoUses-headerH2">Companies using Go</h2>
+
+GET https://go.dev/solutions/google/
+body ~ it\s+has\s+powered\s+many\s+projects\s+at\s+Google.
diff --git a/internal/dl/server.go b/internal/dl/server.go
index 43cc116..dfb8b03 100644
--- a/internal/dl/server.go
+++ b/internal/dl/server.go
@@ -115,26 +115,6 @@
 	}
 }
 
-// googleCN reports whether request r is considered
-// to be served from golang.google.cn.
-// TODO: This is duplicated within internal/proxy. Move to a common location.
-func googleCN(r *http.Request) bool {
-	if r.FormValue("googlecn") != "" {
-		return true
-	}
-	if strings.HasSuffix(r.Host, ".cn") {
-		return true
-	}
-	if !env.CheckCountry() {
-		return false
-	}
-	switch r.Header.Get("X-Appengine-Country") {
-	case "", "ZZ", "CN":
-		return true
-	}
-	return false
-}
-
 func (h server) uploadHandler(w http.ResponseWriter, r *http.Request) {
 	if r.Method != "POST" {
 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
diff --git a/internal/env/env.go b/internal/env/env.go
index 80575a3..3eca79c 100644
--- a/internal/env/env.go
+++ b/internal/env/env.go
@@ -13,7 +13,6 @@
 )
 
 var (
-	checkCountry       = boolEnv("GOLANGORG_CHECK_COUNTRY")
 	enforceHosts       = boolEnv("GOLANGORG_ENFORCE_HOSTS")
 	requireDLSecretKey = boolEnv("GOLANGORG_REQUIRE_DL_SECRET_KEY")
 )
@@ -25,11 +24,6 @@
 	return requireDLSecretKey
 }
 
-// CheckCountry reports whether country restrictions should be enforced.
-func CheckCountry() bool {
-	return checkCountry
-}
-
 // EnforceHosts reports whether host filtering should be enforced.
 func EnforceHosts() bool {
 	return enforceHosts
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index 2843a77..2584b36 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -15,10 +15,9 @@
 	"io/ioutil"
 	"log"
 	"net/http"
-	"strings"
 	"time"
 
-	"golang.org/x/website/internal/env"
+	"golang.org/x/website/internal/web"
 )
 
 const playgroundURL = "https://play.golang.org"
@@ -124,7 +123,7 @@
 }
 
 func share(w http.ResponseWriter, r *http.Request) {
-	if googleCN(r) {
+	if web.GoogleCN(r) {
 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 		return
 	}
@@ -151,22 +150,3 @@
 	w.WriteHeader(resp.StatusCode)
 	io.Copy(w, resp.Body)
 }
-
-// googleCN reports whether request r is considered
-// to be served from golang.google.cn.
-func googleCN(r *http.Request) bool {
-	if r.FormValue("googlecn") != "" {
-		return true
-	}
-	if strings.HasSuffix(r.Host, ".cn") {
-		return true
-	}
-	if !env.CheckCountry() {
-		return false
-	}
-	switch r.Header.Get("X-Appengine-Country") {
-	case "", "ZZ", "CN":
-		return true
-	}
-	return false
-}
diff --git a/internal/web/googlecn.go b/internal/web/googlecn.go
new file mode 100644
index 0000000..82d658b
--- /dev/null
+++ b/internal/web/googlecn.go
@@ -0,0 +1,28 @@
+// Copyright 2021 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 web
+
+import (
+	"net/http"
+	"strings"
+)
+
+// GoogleCN reports whether request r is considered to be arriving from China.
+// Typically that means the request is for host golang.google.cn,
+// but we also report true for requests that set googlecn=1 as a query parameter
+// and requests that App Engine geolocates in China or in “unknown country.”
+func GoogleCN(r *http.Request) bool {
+	if r.FormValue("googlecn") != "" {
+		return true
+	}
+	if strings.HasSuffix(r.Host, ".cn") {
+		return true
+	}
+	switch r.Header.Get("X-Appengine-Country") {
+	case "ZZ", "CN":
+		return true
+	}
+	return false
+}
diff --git a/internal/web/site.go b/internal/web/site.go
index 4383eee..245c99e 100644
--- a/internal/web/site.go
+++ b/internal/web/site.go
@@ -36,10 +36,6 @@
 
 	Templates *template.Template
 
-	// GoogleCN reports whether this request should be marked GoogleCN.
-	// If the function is nil, no requests are marked GoogleCN.
-	GoogleCN func(*http.Request) bool
-
 	// GoogleAnalytics optionally adds Google Analytics via the provided
 	// tracking ID to each page.
 	GoogleAnalytics string
@@ -122,7 +118,7 @@
 	Data     interface{} // data to be rendered into page frame
 
 	// Filled in automatically by ServePage
-	GoogleCN        bool   // page is being served from golang.google.cn
+	GoogleCN        bool   // served on golang.google.cn
 	GoogleAnalytics string // Google Analytics tag
 	Version         string // current Go version
 
@@ -135,7 +131,7 @@
 		page.TabTitle = page.Title
 	}
 	page.Version = runtime.Version()
-	page.GoogleCN = s.googleCN(r)
+	page.GoogleCN = GoogleCN(r)
 	page.GoogleAnalytics = s.GoogleAnalytics
 	page.site = s
 	return page
@@ -413,7 +409,3 @@
 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 	w.Write(text)
 }
-
-func (s *Site) googleCN(r *http.Request) bool {
-	return s.GoogleCN != nil && s.GoogleCN(r)
-}
diff --git a/internal/webtest/webtest.go b/internal/webtest/webtest.go
index 27765f8..6a6e29e 100644
--- a/internal/webtest/webtest.go
+++ b/internal/webtest/webtest.go
@@ -164,6 +164,26 @@
 	"unicode/utf8"
 )
 
+// HandlerWithCheck returns an http.Handler that responds to each request
+// by running the test script files mached by glob against the handler h.
+// If the tests pass, the returned http.Handler responds with status code 200.
+// If they fail, it prints the details and responds with status code 503
+// (service unavailable).
+func HandlerWithCheck(h http.Handler, path, glob string) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == path {
+			err := CheckHandler(glob, h)
+			if err != nil {
+				http.Error(w, "webtest.CheckHandler failed:\n"+err.Error()+"\n", http.StatusInternalServerError)
+			} else {
+				fmt.Fprintf(w, "ok\n")
+			}
+			return
+		}
+		h.ServeHTTP(w, r)
+	})
+}
+
 // CheckHandler runs the test script files matched by glob
 // against the handler h. If any errors are encountered,
 // CheckHandler returns an error listing the problems.
diff --git a/tour/appengine.go b/tour/appengine.go
index fffc9e8..0f02356 100644
--- a/tour/appengine.go
+++ b/tour/appengine.go
@@ -12,8 +12,10 @@
 	"log"
 	"net/http"
 	"os"
+	"path/filepath"
 
 	_ "golang.org/x/tools/playground"
+	"golang.org/x/website/internal/webtest"
 )
 
 func gaeMain() {
@@ -36,7 +38,11 @@
 	if port == "" {
 		port = "8080"
 	}
-	log.Fatal(http.ListenAndServe(":"+port, nil))
+
+	h := webtest.HandlerWithCheck(http.DefaultServeMux, "/_readycheck",
+		filepath.Join(root, "testdata/*.txt"))
+
+	log.Fatal(http.ListenAndServe(":"+port, h))
 }
 
 // gaePrepContent returns a Reader that produces the content from the given
diff --git a/tour/local.go b/tour/local.go
index d64417e..f59edfa 100644
--- a/tour/local.go
+++ b/tour/local.go
@@ -23,6 +23,7 @@
 	"time"
 
 	"golang.org/x/tools/playground/socket"
+	"golang.org/x/website/internal/webtest"
 )
 
 const (
@@ -126,6 +127,9 @@
 
 	registerStatic(root)
 
+	h := webtest.HandlerWithCheck(http.DefaultServeMux, "/_readycheck",
+		filepath.Join(root, "testdata/*.txt"))
+
 	go func() {
 		url := "http://" + httpAddr
 		if waitServer(url) && *openBrowser && startBrowser(url) {
@@ -134,7 +138,7 @@
 			log.Printf("Please open your web browser and visit %s", url)
 		}
 	}()
-	log.Fatal(http.ListenAndServe(httpAddr, nil))
+	log.Fatal(http.ListenAndServe(httpAddr, h))
 }
 
 // registerStatic registers handlers to serve static content
diff --git a/tour/server_test.go b/tour/server_test.go
new file mode 100644
index 0000000..acc6ee3
--- /dev/null
+++ b/tour/server_test.go
@@ -0,0 +1,24 @@
+// Copyright 2021 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"
+	"testing"
+
+	"golang.org/x/website/internal/webtest"
+)
+
+func TestWeb(t *testing.T) {
+	if err := initTour(".", "SocketTransport"); err != nil {
+		log.Fatal(err)
+	}
+	http.HandleFunc("/", rootHandler)
+	http.HandleFunc("/lesson/", lessonHandler)
+	registerStatic(".")
+
+	webtest.TestHandler(t, "testdata/*.txt", http.DefaultServeMux)
+}
diff --git a/tour/testdata/tour.txt b/tour/testdata/tour.txt
new file mode 100644
index 0000000..bb1db63
--- /dev/null
+++ b/tour/testdata/tour.txt
@@ -0,0 +1,3 @@
+GET https://tour.golang.org/
+body contains >A Tour of Go<
+