internal/installer: add packages for constructing Go installers

The logic is taken from the existing code for constructing Go installers
in the internal/task and internal/task/releaselet packages. It now uses
a common interface for both, and has tests that can be used during local
development of said installers.

Neither of the packages use cgo now, and probably won't need to.
Use the '!cgo' build constraints to make cgo use an intentional
decision rather than accidental.

For golang/go#63147.

Change-Id: Id5aaa3503a66d5bbb97721e284ce0eaef1a63574
Reviewed-on: https://go-review.googlesource.com/c/build/+/550321
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/internal/installer/darwinpkg/_data/blue-bg.png b/internal/installer/darwinpkg/_data/blue-bg.png
new file mode 100644
index 0000000..9de0f09
--- /dev/null
+++ b/internal/installer/darwinpkg/_data/blue-bg.png
Binary files differ
diff --git a/internal/installer/darwinpkg/_data/brown-bg.png b/internal/installer/darwinpkg/_data/brown-bg.png
new file mode 100644
index 0000000..9aec274
--- /dev/null
+++ b/internal/installer/darwinpkg/_data/brown-bg.png
Binary files differ
diff --git a/internal/installer/darwinpkg/_data/dist.xml b/internal/installer/darwinpkg/_data/dist.xml
new file mode 100644
index 0000000..11beb5a
--- /dev/null
+++ b/internal/installer/darwinpkg/_data/dist.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8" standalone="no"?>
+<!--
+ Copyright 2023 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.
+-->
+
+<installer-gui-script minSpecVersion="1">
+  <title>Go</title>
+  <background mime-type="image/png" file="background.png" alignment="left" />
+  <background-darkAqua mime-type="image/png" file="background.png" alignment="left" />
+  <options hostArchitectures="{{.HostArchs}}" customize="never" allow-external-scripts="no" />
+  <domains enable_localSystem="true" />
+  <installation-check script="installCheck();" />
+  <script>
+    function installCheck() {
+      if (!(system.compareVersions(system.version.ProductVersion, '{{.MinOS}}') >= 0)) {
+        my.result.title = 'Unable to install';
+        my.result.message = 'Go requires macOS {{.MinOS}} or later.';
+        my.result.type = 'Fatal';
+        return false;
+      }
+      if (system.files.fileExistsAtPath('/usr/local/go/bin/go')) {
+        my.result.title = 'Previous Installation Detected';
+        my.result.message = 'A previous installation of Go exists at /usr/local/go. This installer will remove the previous installation prior to installing. Please back up any data before proceeding.';
+        my.result.type = 'Warning';
+        return false;
+      }
+      return true;
+    }
+  </script>
+  <choices-outline>
+    <line choice="org.golang.go.choice" />
+  </choices-outline>
+  <choice id="org.golang.go.choice" title="Go">
+    <pkg-ref id="org.golang.go.pkg" />
+  </choice>
+  <pkg-ref id="org.golang.go.pkg" auth="Root">org.golang.go.pkg</pkg-ref>
+</installer-gui-script>
diff --git a/internal/installer/darwinpkg/darwinpkg.go b/internal/installer/darwinpkg/darwinpkg.go
new file mode 100644
index 0000000..f4f4385
--- /dev/null
+++ b/internal/installer/darwinpkg/darwinpkg.go
@@ -0,0 +1,198 @@
+// Copyright 2023 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.
+
+//go:build !cgo
+
+// Package darwinpkg encodes the process of building a macOS PKG
+// installer from the given Go toolchain .tar.gz binary archive.
+package darwinpkg
+
+import (
+	"bytes"
+	"context"
+	"embed"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	"golang.org/x/build/internal/untar"
+)
+
+// InstallerOptions holds options for constructing the installer.
+type InstallerOptions struct {
+	GOARCH string // The target GOARCH.
+	// MinMacOSVersion is the minimum required system.version.ProductVersion.
+	// For example, "11" for macOS 11 Big Sur, "10.15" for macOS 10.15 Catalina, etc.
+	MinMacOSVersion string
+}
+
+// ConstructInstaller constructs an installer for the provided Go toolchain .tar.gz
+// binary archive using workDir as a working directory, and returns the output path.
+//
+// It's intended to run on a macOS system with Xcode installed.
+func ConstructInstaller(_ context.Context, workDir, tgzPath string, opt InstallerOptions) (pkgPath string, _ error) {
+	var errs []error
+	if opt.GOARCH == "" {
+		errs = append(errs, fmt.Errorf("GOARCH is empty"))
+	}
+	if opt.MinMacOSVersion == "" {
+		errs = append(errs, fmt.Errorf("MinMacOSVersion is empty"))
+	}
+	if len(errs) > 0 {
+		return "", errors.Join(errs...)
+	}
+
+	origWD, err := os.Getwd()
+	if err != nil {
+		panic(err)
+	}
+	if err := os.Chdir(workDir); err != nil {
+		panic(err)
+	}
+	defer func() {
+		if err := os.Chdir(origWD); err != nil {
+			panic(err)
+		}
+	}()
+
+	fmt.Println("Building inner .pkg with pkgbuild.")
+	run("mkdir", "pkg-intermediate")
+	putTar(tgzPath, "pkg-root/usr/local")
+	put("/usr/local/go/bin\n", "pkg-root/etc/paths.d/go", 0644)
+	put(`#!/bin/bash
+
+GOROOT=/usr/local/go
+echo "Removing previous installation"
+if [ -d $GOROOT ]; then
+	rm -r $GOROOT
+fi
+`, "pkg-scripts/preinstall", 0755)
+	put(`#!/bin/bash
+
+GOROOT=/usr/local/go
+echo "Fixing permissions"
+cd $GOROOT
+find . -exec chmod ugo+r \{\} \;
+find bin -exec chmod ugo+rx \{\} \;
+find . -type d -exec chmod ugo+rx \{\} \;
+chmod o-w .
+`, "pkg-scripts/postinstall", 0755)
+	version := readVERSION("pkg-root/usr/local/go")
+	run("pkgbuild",
+		"--identifier=org.golang.go",
+		"--version", version,
+		"--scripts=pkg-scripts",
+		"--root=pkg-root",
+		"pkg-intermediate/org.golang.go.pkg",
+	)
+
+	fmt.Println("\nBuilding outer .pkg with productbuild.")
+	run("mkdir", "pkg-out")
+	bg, err := darwinPKGBackground(opt.GOARCH)
+	if err != nil {
+		log.Fatalln("darwinPKGBackground:", err)
+	}
+	put(string(bg), "pkg-resources/background.png", 0644)
+	var buf bytes.Buffer
+	distData := darwinDistData{
+		HostArchs: map[string]string{"amd64": "x86_64", "arm64": "arm64"}[opt.GOARCH],
+		MinOS:     opt.MinMacOSVersion,
+	}
+	if err := darwinDistTmpl.ExecuteTemplate(&buf, "dist.xml", distData); err != nil {
+		log.Fatalln("darwinDistTmpl.ExecuteTemplate:", err)
+	}
+	put(buf.String(), "pkg-distribution", 0644)
+	run("productbuild",
+		"--distribution=pkg-distribution",
+		"--resources=pkg-resources",
+		"--package-path=pkg-intermediate",
+		"pkg-out/"+version+"-unsigned.pkg",
+	)
+
+	return filepath.Join(workDir, "pkg-out", version+"-unsigned.pkg"), nil
+}
+
+//go:embed _data
+var darwinPKGData embed.FS
+
+func darwinPKGBackground(goarch string) ([]byte, error) {
+	switch goarch {
+	case "arm64":
+		return darwinPKGData.ReadFile("_data/blue-bg.png")
+	case "amd64":
+		return darwinPKGData.ReadFile("_data/brown-bg.png")
+	default:
+		return nil, fmt.Errorf("no background for GOARCH %q", goarch)
+	}
+}
+
+var darwinDistTmpl = template.Must(template.New("").ParseFS(darwinPKGData, "_data/dist.xml"))
+
+type darwinDistData struct {
+	HostArchs string // hostArchitectures option value.
+	MinOS     string // Minimum required system.version.ProductVersion.
+}
+
+func put(content, dst string, perm fs.FileMode) {
+	err := os.MkdirAll(filepath.Dir(dst), 0755)
+	if err != nil {
+		panic(err)
+	}
+	f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
+	if err != nil {
+		panic(err)
+	}
+	_, err = io.WriteString(f, content)
+	if err != nil {
+		panic(err)
+	}
+	err = f.Close()
+	if err != nil {
+		panic(err)
+	}
+}
+
+func putTar(tgz, dir string) {
+	f, err := os.Open(tgz)
+	if err != nil {
+		panic(err)
+	}
+	err = untar.Untar(f, dir)
+	if err != nil {
+		panic(err)
+	}
+	err = f.Close()
+	if err != nil {
+		panic(err)
+	}
+}
+
+// run runs the command and requires that it succeeds.
+// If not, it logs the failure and exits with a non-zero code.
+// It prints the command line.
+func run(name string, args ...string) {
+	fmt.Printf("$ %s %s\n", name, strings.Join(args, " "))
+	out, err := exec.Command(name, args...).CombinedOutput()
+	if err != nil {
+		log.Fatalf("command failed: %v\n%s", err, out)
+	}
+}
+
+// readVERSION reads the VERSION file and
+// returns the first line of the file, the Go version.
+func readVERSION(goroot string) (version string) {
+	b, err := os.ReadFile(filepath.Join(goroot, "VERSION"))
+	if err != nil {
+		panic(err)
+	}
+	version, _, _ = strings.Cut(string(b), "\n")
+	return version
+}
diff --git a/internal/installer/darwinpkg/darwinpkg_test.go b/internal/installer/darwinpkg/darwinpkg_test.go
new file mode 100644
index 0000000..43c3b4a
--- /dev/null
+++ b/internal/installer/darwinpkg/darwinpkg_test.go
@@ -0,0 +1,40 @@
+// Copyright 2023 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.
+
+//go:build !cgo
+
+package darwinpkg_test
+
+import (
+	"context"
+	"flag"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"golang.org/x/build/internal/installer/darwinpkg"
+)
+
+var (
+	inFlag  = flag.String("in", "", "Path to the .tar.gz archive containing a built Go toolchain.")
+	outFlag = flag.String("out", filepath.Join(os.TempDir(), "out.pkg"), "Path where to write out the result.")
+)
+
+func TestConstructInstaller(t *testing.T) {
+	if *inFlag == "" || *outFlag == "" {
+		t.Skip("skipping manual test since -in/-out flags are not set")
+	}
+
+	out, err := darwinpkg.ConstructInstaller(context.Background(), t.TempDir(), *inFlag, darwinpkg.InstallerOptions{
+		GOARCH:          "arm64",
+		MinMacOSVersion: "11",
+	})
+	if err != nil {
+		t.Fatal("ConstructInstaller:", err)
+	}
+	if err := os.Rename(out, *outFlag); err != nil {
+		t.Fatal("moving result to output location failed:", err)
+	}
+	t.Log("constructed installer at:", *outFlag)
+}
diff --git a/internal/installer/windowsmsi/windowsmsi.go b/internal/installer/windowsmsi/windowsmsi.go
new file mode 100644
index 0000000..702d7fe
--- /dev/null
+++ b/internal/installer/windowsmsi/windowsmsi.go
@@ -0,0 +1,588 @@
+// Copyright 2023 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.
+
+//go:build !cgo
+
+// Package windowsmsi encodes the process of building a Windows MSI
+// installer from the given Go toolchain .tar.gz binary archive.
+package windowsmsi
+
+import (
+	"archive/zip"
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"golang.org/x/build/internal/untar"
+)
+
+// InstallerOptions holds options for constructing the installer.
+type InstallerOptions struct {
+	GOARCH string // The target GOARCH.
+}
+
+// ConstructInstaller constructs an installer for the provided Go toolchain .tar.gz
+// binary archive using workDir as a working directory, and returns the output path.
+//
+// It's intended to run on a Windows system where the WiX tools can run.
+func ConstructInstaller(_ context.Context, workDir, tgzPath string, opt InstallerOptions) (msiPath string, _ error) {
+	var errs []error
+	if opt.GOARCH == "" {
+		errs = append(errs, fmt.Errorf("GOARCH is empty"))
+	}
+	if len(errs) > 0 {
+		return "", errors.Join(errs...)
+	}
+
+	origWD, err := os.Getwd()
+	if err != nil {
+		panic(err)
+	}
+	if err := os.Chdir(workDir); err != nil {
+		panic(err)
+	}
+	defer func() {
+		if err := os.Chdir(origWD); err != nil {
+			panic(err)
+		}
+	}()
+
+	fmt.Println("Extracting the Go toolchain .tar.gz binary archive.")
+	putTar(tgzPath, ".")
+	version := readVERSION("go")
+
+	fmt.Println("\nInstalling WiX tools.")
+	wix := filepath.Join(workDir, "wix")
+	switch opt.GOARCH {
+	default:
+		if err := installWix(wixRelease311, wix); err != nil {
+			return "", err
+		}
+	case "arm", "arm64":
+		if err := installWix(wixRelease314, wix); err != nil {
+			return "", err
+		}
+	}
+
+	fmt.Println("\nWriting out windows data used by the packaging process.")
+	win := filepath.Join(workDir, "windows")
+	if err := writeDataFiles(windowsData, win); err != nil {
+		return "", err
+	}
+
+	fmt.Println("\nGathering files (running wix heat).")
+	goDir := filepath.Join(workDir, "go")
+	appfiles := filepath.Join(win, "AppFiles.wxs")
+	if err := runDir(win, filepath.Join(wix, "heat"),
+		"dir", goDir,
+		"-nologo",
+		"-gg", "-g1", "-srd", "-sfrag", "-sreg",
+		"-cg", "AppFiles",
+		"-template", "fragment",
+		"-dr", "INSTALLDIR",
+		"-var", "var.SourceDir",
+		"-out", appfiles,
+	); err != nil {
+		return "", err
+	}
+
+	fmt.Println("\nBuilding package (running wix candle).")
+	verMajor, verMinor := splitVersion(version)
+	var msArch string
+	switch opt.GOARCH {
+	case "386":
+		msArch = "x86"
+	case "amd64":
+		msArch = "x64"
+	case "arm":
+		// Historically the installer for the windows/arm port
+		// used the same value as for the windows/arm64 port.
+		fallthrough
+	case "arm64":
+		msArch = "arm64"
+	default:
+		panic("unknown arch for windows " + opt.GOARCH)
+	}
+	if err := runDir(win, filepath.Join(wix, "candle"),
+		"-nologo",
+		"-arch", msArch,
+		"-dGoVersion="+version,
+		fmt.Sprintf("-dGoMajorVersion=%v", verMajor),
+		fmt.Sprintf("-dWixGoVersion=1.%v.%v", verMajor, verMinor),
+		"-dArch="+opt.GOARCH,
+		"-dSourceDir="+goDir,
+		filepath.Join(win, "installer.wxs"),
+		appfiles,
+	); err != nil {
+		return "", err
+	}
+
+	fmt.Println("\nLinking the .msi installer (running wix light).")
+	msi := filepath.Join(workDir, "msi")
+	if err := os.Mkdir(msi, 0755); err != nil {
+		return "", err
+	}
+	if err := runDir(win, filepath.Join(wix, "light"),
+		"-nologo",
+		"-dcl:high",
+		"-ext", "WixUIExtension",
+		"-ext", "WixUtilExtension",
+		"AppFiles.wixobj",
+		"installer.wixobj",
+		"-o", filepath.Join(msi, version+"-unsigned.msi"),
+	); err != nil {
+		return "", err
+	}
+
+	return filepath.Join(msi, version+"-unsigned.msi"), nil
+}
+
+type wixRelease struct {
+	BinaryURL string
+	SHA256    string
+}
+
+var (
+	wixRelease311 = wixRelease{
+		BinaryURL: "https://storage.googleapis.com/go-builder-data/wix311-binaries.zip",
+		SHA256:    "da034c489bd1dd6d8e1623675bf5e899f32d74d6d8312f8dd125a084543193de",
+	}
+	wixRelease314 = wixRelease{
+		BinaryURL: "https://storage.googleapis.com/go-builder-data/wix314-binaries.zip",
+		SHA256:    "34dcbba9952902bfb710161bd45ee2e721ffa878db99f738285a21c9b09c6edb", // WiX v3.14.0.4118 release, SHA 256 of wix314-binaries.zip from https://wixtoolset.org/releases/v3-14-0-4118/.
+	}
+)
+
+// installWix fetches and installs the wix toolkit to the specified path.
+func installWix(wix wixRelease, path string) error {
+	// Fetch wix binary zip file.
+	body, err := httpGet(wix.BinaryURL)
+	if err != nil {
+		return err
+	}
+
+	// Verify sha256.
+	sum := sha256.Sum256(body)
+	if fmt.Sprintf("%x", sum) != wix.SHA256 {
+		return errors.New("sha256 mismatch for wix toolkit")
+	}
+
+	// Unzip to path.
+	zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
+	if err != nil {
+		return err
+	}
+	for _, f := range zr.File {
+		name := filepath.FromSlash(f.Name)
+		err := os.MkdirAll(filepath.Join(path, filepath.Dir(name)), 0755)
+		if err != nil {
+			return err
+		}
+		rc, err := f.Open()
+		if err != nil {
+			return err
+		}
+		b, err := io.ReadAll(rc)
+		rc.Close()
+		if err != nil {
+			return err
+		}
+		err = os.WriteFile(filepath.Join(path, name), b, 0644)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func httpGet(url string) ([]byte, error) {
+	r, err := http.Get(url)
+	if err != nil {
+		return nil, err
+	}
+	body, err := io.ReadAll(r.Body)
+	r.Body.Close()
+	if err != nil {
+		return nil, err
+	}
+	if r.StatusCode != 200 {
+		return nil, errors.New(r.Status)
+	}
+	return body, nil
+}
+
+func putTar(tgz, dir string) {
+	f, err := os.Open(tgz)
+	if err != nil {
+		panic(err)
+	}
+	err = untar.Untar(f, dir)
+	if err != nil {
+		panic(err)
+	}
+	err = f.Close()
+	if err != nil {
+		panic(err)
+	}
+}
+
+func run(name string, args ...string) error {
+	fmt.Printf("$ %s %s\n", name, strings.Join(args, " "))
+	cmd := exec.Command(name, args...)
+	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+	return cmd.Run()
+}
+
+func runDir(dir, name string, args ...string) error {
+	fmt.Printf("%s $ %s %s\n", filepath.Base(dir), name, strings.Join(args, " "))
+	cmd := exec.Command(name, args...)
+	cmd.Dir = dir
+	if dir != "" {
+		cmd.Env = append(os.Environ(), "PWD="+dir)
+	}
+	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+	return cmd.Run()
+}
+
+var versionRe = regexp.MustCompile(`^go1\.(\d+(\.\d+)?)`)
+
+// splitVersion splits a Go version string such as "go1.9" or "go1.10.2" (as matched by versionRe)
+// into its parts: major and minor.
+func splitVersion(v string) (major, minor int) {
+	m := versionRe.FindStringSubmatch(v)
+	if m == nil {
+		return
+	}
+	parts := strings.Split(m[1], ".")
+	if len(parts) >= 1 {
+		major, _ = strconv.Atoi(parts[0])
+
+		if len(parts) >= 2 {
+			minor, _ = strconv.Atoi(parts[1])
+		}
+	}
+	return
+}
+
+const storageBase = "https://storage.googleapis.com/go-builder-data/release/"
+
+// writeDataFiles writes the files in the provided map to the provided base
+// directory. If the map value is a URL it fetches the data at that URL and
+// uses it as the file contents.
+func writeDataFiles(data map[string]string, base string) error {
+	for name, body := range data {
+		dst := filepath.Join(base, name)
+		err := os.MkdirAll(filepath.Dir(dst), 0755)
+		if err != nil {
+			return err
+		}
+		b := []byte(body)
+		if strings.HasPrefix(body, storageBase) {
+			b, err = httpGet(body)
+			if err != nil {
+				return err
+			}
+		}
+		// (We really mean 0755 on the next line; some of these files
+		// are executable, and there's no harm in making them all so.)
+		if err := os.WriteFile(dst, b, 0755); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+var windowsData = map[string]string{
+
+	"installer.wxs": `<?xml version="1.0" encoding="UTF-8"?>
+<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
+<!--
+# Copyright 2010 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.
+-->
+
+<?if $(var.Arch) = 386 ?>
+  <?define UpgradeCode = {1C3114EA-08C3-11E1-9095-7FCA4824019B} ?>
+  <?define InstallerVersion="300" ?>
+  <?define SysFolder=SystemFolder ?>
+  <?define ArchProgramFilesFolder="ProgramFilesFolder" ?>
+<?elseif $(var.Arch) = arm64 ?>
+  <?define UpgradeCode = {21ade9a3-3fdd-4ba6-bea6-c85abadc9488} ?>
+  <?define InstallerVersion="500" ?>
+  <?define SysFolder=System64Folder ?>
+  <?define ArchProgramFilesFolder="ProgramFiles64Folder" ?>
+<?else?>
+  <?define UpgradeCode = {22ea7650-4ac6-4001-bf29-f4b8775db1c0} ?>
+  <?define InstallerVersion="300" ?>
+  <?define SysFolder=System64Folder ?>
+  <?define ArchProgramFilesFolder="ProgramFiles64Folder" ?>
+<?endif?>
+
+<Product
+    Id="*"
+    Name="Go Programming Language $(var.Arch) $(var.GoVersion)"
+    Language="1033"
+    Version="$(var.WixGoVersion)"
+    Manufacturer="https://go.dev"
+    UpgradeCode="$(var.UpgradeCode)" >
+
+<Package
+    Id='*'
+    Keywords='Installer'
+    Description="The Go Programming Language Installer"
+    Comments="The Go programming language is an open source project to make programmers more productive."
+    InstallerVersion="$(var.InstallerVersion)"
+    Compressed="yes"
+    InstallScope="perMachine"
+    Languages="1033" />
+
+<Property Id="ARPCOMMENTS" Value="The Go programming language is a fast, statically typed, compiled language that feels like a dynamically typed, interpreted language." />
+<Property Id="ARPCONTACT" Value="golang-nuts@googlegroups.com" />
+<Property Id="ARPHELPLINK" Value="https://go.dev/help" />
+<Property Id="ARPREADME" Value="https://go.dev" />
+<Property Id="ARPURLINFOABOUT" Value="https://go.dev" />
+<Property Id="LicenseAccepted">1</Property>
+<Icon Id="gopher.ico" SourceFile="images\gopher.ico"/>
+<Property Id="ARPPRODUCTICON" Value="gopher.ico" />
+<Property Id="EXISTING_GOLANG_INSTALLED">
+  <RegistrySearch Id="installed" Type="raw" Root="HKCU" Key="Software\GoProgrammingLanguage" Name="installed" />
+</Property>
+<MediaTemplate EmbedCab="yes" CompressionLevel="high" MaximumUncompressedMediaSize="10" />
+<?if $(var.GoMajorVersion) < 21 ?>
+<Condition Message="Windows 7 (with Service Pack 1) or greater required.">
+    ((VersionNT > 601) OR (VersionNT = 601 AND ServicePackLevel >= 1))
+</Condition>
+<?else?>
+<Condition Message="Windows 10 or greater required.">
+<!-- In true MS fashion, Windows 10 pretends to be windows 8.1.
+	See https://learn.microsoft.com/en-us/troubleshoot/windows-client/application-management/versionnt-value-for-windows-10-server .
+	Workarounds exist, but seem difficult/flaky.
+	1) We could build a "bootstrapper" with wix burn, but then we'll be building .exes and there might be implications to that.
+	2) We can try one of the things listed here: https://stackoverflow.com/q/31932646 but that takes us back to https://github.com/wixtoolset/issues/issues/5824 and needing a bootstrapper.
+	So we're stuck with checking for 8.1.
+-->
+    (VersionNT >= 603)
+</Condition>
+<?endif?>
+<MajorUpgrade AllowDowngrades="yes" />
+
+<CustomAction
+    Id="SetApplicationRootDirectory"
+    Property="ARPINSTALLLOCATION"
+    Value="[INSTALLDIR]" />
+
+<!-- Define the directory structure and environment variables -->
+<Directory Id="TARGETDIR" Name="SourceDir">
+  <Directory Id="$(var.ArchProgramFilesFolder)">
+    <Directory Id="INSTALLDIR" Name="Go"/>
+  </Directory>
+  <Directory Id="ProgramMenuFolder">
+    <Directory Id="GoProgramShortcutsDir" Name="Go Programming Language"/>
+  </Directory>
+  <Directory Id="EnvironmentEntries">
+    <Directory Id="GoEnvironmentEntries" Name="Go Programming Language"/>
+  </Directory>
+</Directory>
+
+<!-- Programs Menu Shortcuts -->
+<DirectoryRef Id="GoProgramShortcutsDir">
+  <Component Id="Component_GoProgramShortCuts" Guid="{f5fbfb5e-6c5c-423b-9298-21b0e3c98f4b}">
+    <Shortcut
+        Id="UninstallShortcut"
+        Name="Uninstall Go"
+        Description="Uninstalls Go and all of its components"
+        Target="[$(var.SysFolder)]msiexec.exe"
+        Arguments="/x [ProductCode]" />
+    <RemoveFolder
+        Id="GoProgramShortcutsDir"
+        On="uninstall" />
+    <RegistryValue
+        Root="HKCU"
+        Key="Software\GoProgrammingLanguage"
+        Name="ShortCuts"
+        Type="integer"
+        Value="1"
+        KeyPath="yes" />
+  </Component>
+</DirectoryRef>
+
+<!-- Registry & Environment Settings -->
+<DirectoryRef Id="GoEnvironmentEntries">
+  <Component Id="Component_GoEnvironment" Guid="{3ec7a4d5-eb08-4de7-9312-2df392c45993}">
+    <RegistryKey
+        Root="HKCU"
+        Key="Software\GoProgrammingLanguage">
+            <RegistryValue
+                Name="installed"
+                Type="integer"
+                Value="1"
+                KeyPath="yes" />
+            <RegistryValue
+                Name="installLocation"
+                Type="string"
+                Value="[INSTALLDIR]" />
+    </RegistryKey>
+    <Environment
+        Id="GoPathEntry"
+        Action="set"
+        Part="last"
+        Name="PATH"
+        Permanent="no"
+        System="yes"
+        Value="[INSTALLDIR]bin" />
+    <Environment
+        Id="UserGoPath"
+        Action="create"
+        Name="GOPATH"
+        Permanent="no"
+        Value="%USERPROFILE%\go" />
+    <Environment
+        Id="UserGoPathEntry"
+        Action="set"
+        Part="last"
+        Name="PATH"
+        Permanent="no"
+        Value="%USERPROFILE%\go\bin" />
+    <RemoveFolder
+        Id="GoEnvironmentEntries"
+        On="uninstall" />
+  </Component>
+</DirectoryRef>
+
+<!-- Install the files -->
+<Feature
+    Id="GoTools"
+    Title="Go"
+    Level="1">
+      <ComponentRef Id="Component_GoEnvironment" />
+      <ComponentGroupRef Id="AppFiles" />
+      <ComponentRef Id="Component_GoProgramShortCuts" />
+</Feature>
+
+<!-- Update the environment -->
+<InstallExecuteSequence>
+    <Custom Action="SetApplicationRootDirectory" Before="InstallFinalize" />
+</InstallExecuteSequence>
+
+<!-- Notify top level applications of the new PATH variable (go.dev/issue/18680)  -->
+<CustomActionRef Id="WixBroadcastEnvironmentChange" />
+
+<!-- Include the user interface -->
+<WixVariable Id="WixUILicenseRtf" Value="LICENSE.rtf" />
+<WixVariable Id="WixUIBannerBmp" Value="images\Banner.jpg" />
+<WixVariable Id="WixUIDialogBmp" Value="images\Dialog.jpg" />
+<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
+<UIRef Id="Golang_InstallDir" />
+<UIRef Id="WixUI_ErrorProgressText" />
+
+</Product>
+<Fragment>
+  <!--
+    The installer steps are modified so we can get user confirmation to uninstall an existing golang installation.
+
+    WelcomeDlg  [not installed]  =>                  LicenseAgreementDlg => InstallDirDlg  ..
+                [installed]      => OldVersionDlg => LicenseAgreementDlg => InstallDirDlg  ..
+  -->
+  <UI Id="Golang_InstallDir">
+    <!-- style -->
+    <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
+    <TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
+    <TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />
+
+    <Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
+    <Property Id="WixUI_Mode" Value="InstallDir" />
+
+    <!-- dialogs -->
+    <DialogRef Id="BrowseDlg" />
+    <DialogRef Id="DiskCostDlg" />
+    <DialogRef Id="ErrorDlg" />
+    <DialogRef Id="FatalError" />
+    <DialogRef Id="FilesInUse" />
+    <DialogRef Id="MsiRMFilesInUse" />
+    <DialogRef Id="PrepareDlg" />
+    <DialogRef Id="ProgressDlg" />
+    <DialogRef Id="ResumeDlg" />
+    <DialogRef Id="UserExit" />
+    <Dialog Id="OldVersionDlg" Width="240" Height="95" Title="[ProductName] Setup" NoMinimize="yes">
+      <Control Id="Text" Type="Text" X="28" Y="15" Width="194" Height="50">
+        <Text>A previous version of Go Programming Language is currently installed. By continuing the installation this version will be uninstalled. Do you want to continue?</Text>
+      </Control>
+      <Control Id="Exit" Type="PushButton" X="123" Y="67" Width="62" Height="17"
+        Default="yes" Cancel="yes" Text="No, Exit">
+        <Publish Event="EndDialog" Value="Exit">1</Publish>
+      </Control>
+      <Control Id="Next" Type="PushButton" X="55" Y="67" Width="62" Height="17" Text="Yes, Uninstall">
+        <Publish Event="EndDialog" Value="Return">1</Publish>
+      </Control>
+    </Dialog>
+
+    <!-- wizard steps -->
+    <Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath" Order="3">1</Publish>
+    <Publish Dialog="BrowseDlg" Control="OK" Event="SpawnDialog" Value="InvalidDirDlg" Order="4"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
+
+    <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish>
+
+    <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="OldVersionDlg"><![CDATA[EXISTING_GOLANG_INSTALLED << "#1"]]> </Publish>
+    <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="LicenseAgreementDlg"><![CDATA[NOT (EXISTING_GOLANG_INSTALLED << "#1")]]></Publish>
+
+    <Publish Dialog="OldVersionDlg" Control="Next" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
+
+    <Publish Dialog="LicenseAgreementDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
+    <Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg">LicenseAccepted = "1"</Publish>
+
+    <Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
+    <Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
+    <Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath" Order="2">NOT WIXUI_DONTVALIDATEPATH</Publish>
+    <Publish Dialog="InstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
+    <Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4">WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"</Publish>
+    <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
+    <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>
+
+    <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg" Order="1">NOT Installed</Publish>
+    <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2">Installed AND NOT PATCH</Publish>
+    <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">Installed AND PATCH</Publish>
+
+    <Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg">1</Publish>
+
+    <Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
+    <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
+    <Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg">1</Publish>
+
+    <Property Id="ARPNOMODIFY" Value="1" />
+  </UI>
+
+  <UIRef Id="WixUI_Common" />
+</Fragment>
+</Wix>
+`,
+
+	"LICENSE.rtf":           storageBase + "windows/LICENSE.rtf",
+	"images/Banner.jpg":     storageBase + "windows/Banner.jpg",
+	"images/Dialog.jpg":     storageBase + "windows/Dialog.jpg",
+	"images/DialogLeft.jpg": storageBase + "windows/DialogLeft.jpg",
+	"images/gopher.ico":     storageBase + "windows/gopher.ico",
+}
+
+// readVERSION reads the VERSION file and
+// returns the first line of the file, the Go version.
+func readVERSION(goroot string) (version string) {
+	b, err := os.ReadFile(filepath.Join(goroot, "VERSION"))
+	if err != nil {
+		panic(err)
+	}
+	version, _, _ = strings.Cut(string(b), "\n")
+	return version
+}
diff --git a/internal/installer/windowsmsi/windowsmsi_test.go b/internal/installer/windowsmsi/windowsmsi_test.go
new file mode 100644
index 0000000..6bedca9
--- /dev/null
+++ b/internal/installer/windowsmsi/windowsmsi_test.go
@@ -0,0 +1,55 @@
+// Copyright 2023 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.
+
+//go:build !cgo
+
+package windowsmsi
+
+import (
+	"context"
+	"flag"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+var (
+	inFlag  = flag.String("in", "", "Path to the .tar.gz archive containing a built Go toolchain.")
+	outFlag = flag.String("out", filepath.Join(os.TempDir(), "out.msi"), "Path where to write out the result.")
+)
+
+func TestConstructInstaller(t *testing.T) {
+	if *inFlag == "" || *outFlag == "" {
+		t.Skip("skipping manual test since -in/-out flags are not set")
+	}
+
+	out, err := ConstructInstaller(context.Background(), t.TempDir(), *inFlag, InstallerOptions{
+		GOARCH: "amd64",
+	})
+	if err != nil {
+		t.Fatal("ConstructInstaller:", err)
+	}
+	if err := os.Rename(out, *outFlag); err != nil {
+		t.Fatal("moving result to output location failed:", err)
+	}
+	t.Log("constructed installer at:", *outFlag)
+}
+
+func TestSplitVersion(t *testing.T) {
+	// Test splitVersion.
+	for _, tt := range []struct {
+		v            string
+		minor, patch int
+	}{
+		{"go1", 0, 0},
+		{"go1.34", 34, 0},
+		{"go1.34.7", 34, 7},
+	} {
+		minor, patch := splitVersion(tt.v)
+		if minor != tt.minor || patch != tt.patch {
+			t.Errorf("splitVersion(%q) = %v, %v; want %v, %v",
+				tt.v, minor, patch, tt.minor, tt.patch)
+		}
+	}
+}