all: use the previous major Go release to bootstrap the build

To avoid having to maintain GO_BOOTSTRAP_VERSION in the playground
Dockerfile, always use the latest "published" minor of the previous Go
release as the bootstrap version, which per golang/go#54265 should
always be a sufficiently recent bootstrap version.

Here "published" means that the toolchain must exist, since it will be
downloaded for bootstrap. To enable this, add a `-toolchain` flag to the
latestgo command, which selects versions from the set of published
toolchains, rather than from Gerrit tags.

Fixes golang/go#69238

Change-Id: Ib4d4d7f2c0d5c4fbdccfec5d8bb83c040e0c5384
Reviewed-on: https://go-review.googlesource.com/c/playground/+/610675
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
diff --git a/Dockerfile b/Dockerfile
index 4d26bff..e5e18f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,6 +12,14 @@
 # version of Go. See the configuration in the deploy directory.
 ARG GO_VERSION=go1.22.6
 
+# GO_BOOTSTRAP_VERSION is downloaded below and used to bootstrap the build from
+# source. Therefore, this should be a version that is guaranteed to have
+# published artifacts, such as the latest minor of the previous major Go
+# release.
+#
+# See also https://go.dev/issue/69238.
+ARG GO_BOOSTRAP_VERSION=go1.22.6
+
 ############################################################################
 # Build Go at GO_VERSION, and build faketime standard library.
 FROM debian:buster AS build-go
@@ -22,9 +30,12 @@
 
 ENV GOPATH /go
 ENV GOROOT_BOOTSTRAP=/usr/local/go-bootstrap
-ENV GO_BOOTSTRAP_VERSION go1.22.6
+
+# https://docs.docker.com/reference/dockerfile/#understand-how-arg-and-from-interact
 ARG GO_VERSION
 ENV GO_VERSION ${GO_VERSION}
+ARG GO_BOOTSTRAP_VERSION
+ENV GO_BOOTSTRAP_VERSION ${GO_BOOTSTRAP_VERSION}
 
 # Get a bootstrap version of Go for building GO_VERSION. At the time
 # of this Dockerfile being built, GO_VERSION's artifacts may not yet
diff --git a/cmd/latestgo/main.go b/cmd/latestgo/main.go
index d313fde..1b4f67b 100644
--- a/cmd/latestgo/main.go
+++ b/cmd/latestgo/main.go
@@ -7,9 +7,12 @@
 
 import (
 	"context"
+	"encoding/json"
 	"flag"
 	"fmt"
+	"io"
 	"log"
+	"net/http"
 	"sort"
 	"strings"
 	"time"
@@ -18,16 +21,38 @@
 	"golang.org/x/build/maintner/maintnerd/maintapi/version"
 )
 
-var prev = flag.Bool("prev", false, "whether to query the previous Go release, rather than the last (e.g. 1.17 versus 1.18)")
+var (
+	prev      = flag.Bool("prev", false, "if set, query the previous Go release rather than the last (e.g. 1.17 versus 1.18)")
+	toolchain = flag.Bool("toolchain", false, "if set, query released toolchains, rather than gerrit tags; toolchains may lag behind gerrit")
+)
 
 func main() {
-	client := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
-
 	flag.Parse()
 
 	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
 	defer cancel()
 
+	var latest []string
+	if *toolchain {
+		latest = latestToolchainVersions(ctx)
+	} else {
+		client := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
+		latest = latestGerritVersions(ctx, client)
+	}
+	if len(latest) < 2 {
+		log.Fatalf("found %d versions, need at least 2", len(latest))
+	}
+
+	if *prev {
+		fmt.Println(latest[1])
+	} else {
+		fmt.Println(latest[0])
+	}
+}
+
+// latestGerritVersions queries the latest versions for each major Go release,
+// among Gerrit tags.
+func latestGerritVersions(ctx context.Context, client *gerrit.Client) []string {
 	tagInfo, err := client.GetProjectTags(ctx, "go")
 	if err != nil {
 		log.Fatalf("error retrieving project tags for 'go': %v", err)
@@ -37,6 +62,54 @@
 		log.Fatalln("no project tags found for 'go'")
 	}
 
+	var tags []string
+	for _, tag := range tagInfo {
+		tags = append(tags, strings.TrimPrefix(tag.Ref, "refs/tags/"))
+	}
+	return latestPatches(tags)
+}
+
+// latestToolchainVersions queries the latest versions for each major Go
+// release, among published toolchains. It may have fewer versions than
+// [latestGerritVersions], because not all toolchains may be published.
+func latestToolchainVersions(ctx context.Context) []string {
+	req, err := http.NewRequestWithContext(ctx, "GET", "https://go.dev/dl/?mode=json", nil)
+	if err != nil {
+		log.Fatalf("NewRequest: %v", err)
+	}
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		log.Fatalf("fetching toolchains: %v", err)
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusOK {
+		log.Fatalf("fetching toolchains: got status %d, want 200", res.StatusCode)
+	}
+	data, err := io.ReadAll(res.Body)
+	if err != nil {
+		log.Fatalf("reading body: %v", err)
+	}
+
+	type release struct {
+		Version string `json:"version"`
+	}
+	var releases []release
+	if err := json.Unmarshal(data, &releases); err != nil {
+		log.Fatalf("unmarshaling releases JSON: %v", err)
+	}
+	var all []string
+	for _, rel := range releases {
+		all = append(all, rel.Version)
+	}
+	return latestPatches(all)
+}
+
+// latestPatches returns the latest minor release of each major Go version,
+// among the set of tag or tag-like strings. The result is in descending
+// order, such that later versions are sorted first.
+//
+// Tags that aren't of the form goX, goX.Y, or goX.Y.Z are ignored.
+func latestPatches(tags []string) []string {
 	// Find the latest patch version for each major Go version.
 	type majMin struct {
 		maj, min int // maj, min in semver terminology, which corresponds to a major go release
@@ -47,16 +120,14 @@
 	}
 	latestPatches := make(map[majMin]patchTag) // (maj, min) -> latest patch info
 
-	for _, tag := range tagInfo {
-		tagName := strings.TrimPrefix(tag.Ref, "refs/tags/")
-		maj, min, patch, ok := version.ParseTag(tagName)
+	for _, tag := range tags {
+		maj, min, patch, ok := version.ParseTag(tag)
 		if !ok {
 			continue
 		}
-
 		mm := majMin{maj, min}
 		if latest, ok := latestPatches[mm]; !ok || latest.patch < patch {
-			latestPatches[mm] = patchTag{patch, tagName}
+			latestPatches[mm] = patchTag{patch, tag}
 		}
 	}
 
@@ -64,18 +135,17 @@
 	for mm := range latestPatches {
 		mms = append(mms, mm)
 	}
+	// Sort by descending semantic ordering, so that later versions are first.
 	sort.Slice(mms, func(i, j int) bool {
 		if mms[i].maj != mms[j].maj {
-			return mms[i].maj < mms[j].maj
+			return mms[i].maj > mms[j].maj
 		}
-		return mms[i].min < mms[j].min
+		return mms[i].min > mms[j].min
 	})
 
-	var mm majMin
-	if *prev && len(mms) > 1 {
-		mm = mms[len(mms)-2] // latest patch of the previous Go release
-	} else {
-		mm = mms[len(mms)-1]
+	var latest []string
+	for _, mm := range mms {
+		latest = append(latest, latestPatches[mm].tag)
 	}
-	fmt.Print(latestPatches[mm].tag)
+	return latest
 }
diff --git a/deploy/deploy.json b/deploy/deploy.json
index 43e4247..db7ca68 100644
--- a/deploy/deploy.json
+++ b/deploy/deploy.json
@@ -9,11 +9,19 @@
       ]
     },
     {
+      "name": "golang",
+      "entrypoint": "sh",
+      "args": [
+        "-c",
+        "go run golang.org/x/playground/cmd/latestgo -prev -toolchain > /workspace/gobootstrapversion && echo GO_BOOTSTRAP_VERSION=`cat /workspace/gobootstrapversion`"
+      ]
+    },
+    {
       "name": "gcr.io/cloud-builders/docker",
       "entrypoint": "sh",
       "args": [
         "-c",
-        "docker build --build-arg GO_VERSION=`cat /workspace/goversion` -t gcr.io/$PROJECT_ID/playground ."
+        "docker build --build-arg GO_VERSION=`cat /workspace/goversion` --build-arg GO_BOOTSTRAP_VERSION=`cat /workspace/gobootstrapversion` -t gcr.io/$PROJECT_ID/playground ."
       ]
     },
     {
diff --git a/deploy/deploy_goprev.json b/deploy/deploy_goprev.json
index e97e180..8edfbd9 100644
--- a/deploy/deploy_goprev.json
+++ b/deploy/deploy_goprev.json
@@ -9,11 +9,19 @@
       ]
     },
     {
+      "name": "golang",
+      "entrypoint": "sh",
+      "args": [
+        "-c",
+        "go run golang.org/x/playground/cmd/latestgo -prev -toolchain > /workspace/gobootstrapversion && echo GO_BOOTSTRAP_VERSION=`cat /workspace/gobootstrapversion`"
+      ]
+    },
+    {
       "name": "gcr.io/cloud-builders/docker",
       "entrypoint": "sh",
       "args": [
         "-c",
-        "docker build --build-arg GO_VERSION=`cat /workspace/goversion` -t gcr.io/$PROJECT_ID/playground-goprev ."
+        "docker build --build-arg GO_VERSION=`cat /workspace/goversion` --build_arg GO_BOOTSTRAP_VERSION=`cat /workspace/gobootstrapversion` -t gcr.io/$PROJECT_ID/playground-goprev ."
       ]
     },
     {
diff --git a/deploy/deploy_gotip.json b/deploy/deploy_gotip.json
index cafeb16..4eb8b17 100644
--- a/deploy/deploy_gotip.json
+++ b/deploy/deploy_gotip.json
@@ -1,11 +1,19 @@
 {
   "steps": [
     {
+      "name": "golang",
+      "entrypoint": "sh",
+      "args": [
+        "-c",
+        "go run golang.org/x/playground/cmd/latestgo -prev -toolchain > /workspace/gobootstrapversion && echo GO_BOOTSTRAP_VERSION=`cat /workspace/gobootstrapversion`"
+      ]
+    },
+    {
       "name": "gcr.io/cloud-builders/docker",
       "entrypoint": "sh",
       "args": [
         "-c",
-        "docker build --build-arg GO_VERSION=master -t gcr.io/$PROJECT_ID/playground-gotip ."
+        "docker build --build-arg GO_VERSION=master --build-arg GO_BOOTSTRAP_VERSION=`cat /workspace/gobootstrapversion` -t gcr.io/$PROJECT_ID/playground-gotip ."
       ]
     },
     {