internal/relui: add a step to wait for signing

For the moment, signing will be run out-of-band with the release
process. Add a step that waits for it to complete.

Because our signing process is rough around the edges there are a lot of
subtleties to deal with: we don't produce signatures for zip files,
files only appear in /signed/ if they were modified by the signing
process, etc. Most inconveniently, the .pkg files are produced by the
signing process, which doesn't fit the model I was going for and
requires them to be injected at signing time.

For golang/go#51797.

Change-Id: I6d0b5ffe8b75f4a0d66de3f059d166c843cd0209
Reviewed-on: https://go-review.googlesource.com/c/build/+/410014
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Run-TryBot: Heschi Kreinick <heschi@google.com>
Auto-Submit: Heschi Kreinick <heschi@google.com>
diff --git a/internal/relui/buildrelease_test.go b/internal/relui/buildrelease_test.go
index a5e477f..333251e 100644
--- a/internal/relui/buildrelease_test.go
+++ b/internal/relui/buildrelease_test.go
@@ -10,6 +10,7 @@
 	"bytes"
 	"compress/gzip"
 	"context"
+	"crypto/sha256"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -25,6 +26,7 @@
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"github.com/google/uuid"
 	"golang.org/x/build/buildlet"
 	"golang.org/x/build/internal/untar"
@@ -32,6 +34,8 @@
 )
 
 func TestRelease(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
 	if runtime.GOOS != "linux" {
 		t.Skip("Requires bash shell scripting support.")
 	}
@@ -44,6 +48,8 @@
 		logs:    map[string][]*[]string{},
 	}
 	stagingDir := t.TempDir()
+	go fakeSign(ctx, t, filepath.Join(stagingDir, "go1.18releasetest1"))
+	signingPollDuration = 100 * time.Millisecond
 	tasks := BuildReleaseTasks{
 		GerritURL:      s.URL,
 		GCSClient:      nil,
@@ -63,13 +69,16 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	out, err := w.Run(context.TODO(), &verboseListener{t})
+	out, err := w.Run(ctx, &verboseListener{t})
 	if err != nil {
 		t.Fatal(err)
 	}
-	artifacts := out["Staged artifacts"].([]artifact)
+	artifacts := out["Signed artifacts"].([]artifact)
 	byName := map[string]artifact{}
 	for _, a := range artifacts {
+		if a.sha256 == "" || a.size < 1 || a.suffix == "" || a.filename == "" {
+			t.Errorf("release process produced an invalid artifact: %#v", a)
+		}
 		byName[a.filename] = a
 	}
 
@@ -77,7 +86,7 @@
 		"go/VERSION":       "go1.18releasetest1",
 		"go/src/make.bash": makeScript,
 	})
-	checkMSI(t, stagingDir, byName["go1.18releasetest1.windows-amd64.msi"])
+	checkContents(t, stagingDir, byName["go1.18releasetest1.windows-amd64.msi"], "I'm an MSI!\n")
 	checkTGZ(t, stagingDir, byName["go1.18releasetest1.linux-amd64.tar.gz"], map[string]string{
 		"go/VERSION":                        "go1.18releasetest1",
 		"go/tool/something_orother/compile": "",
@@ -91,6 +100,7 @@
 		"go/VERSION":                        "go1.18releasetest1",
 		"go/tool/something_orother/compile": "",
 	})
+	checkContents(t, stagingDir, byName["go1.18releasetest1.darwin-amd64.pkg"], "I'm a .pkg!\n")
 
 	// TODO: consider logging this to golden files?
 	for name, logs := range fakeBuildlets.logs {
@@ -185,21 +195,21 @@
 	}
 }
 
-func checkMSI(t *testing.T, stagingDir string, a artifact) {
+func checkContents(t *testing.T, stagingDir string, a artifact, contents string) {
 	t.Run(a.filename, func(t *testing.T) {
-		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.stagingPath))
+		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.signedPath))
 		if err != nil {
 			t.Fatalf("reading %v: %v", a.filename, err)
 		}
-		if got, want := string(b), "I'm an MSI!\n"; got != want {
-			t.Fatalf("%v contains %q, want %q", a.filename, got, want)
+		if got, want := string(b), contents; got != want {
+			t.Errorf("%v contains %q, want %q", a.filename, got, want)
 		}
 	})
 }
 
 func checkTGZ(t *testing.T, stagingDir string, a artifact, contents map[string]string) {
 	t.Run(a.filename, func(t *testing.T) {
-		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.stagingPath))
+		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.signedPath))
 		if err != nil {
 			t.Fatalf("reading %v: %v", a.filename, err)
 		}
@@ -230,14 +240,14 @@
 			}
 		}
 		if len(contents) != 0 {
-			t.Fatalf("not all files were found: missing %v", contents)
+			t.Errorf("not all files were found: missing %v", contents)
 		}
 	})
 }
 
 func checkZip(t *testing.T, stagingDir string, a artifact, contents map[string]string) {
 	t.Run(a.filename, func(t *testing.T) {
-		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.stagingPath))
+		b, err := ioutil.ReadFile(filepath.Join(stagingDir, a.signedPath))
 		if err != nil {
 			t.Fatalf("reading %v: %v", a.filename, err)
 		}
@@ -264,7 +274,7 @@
 			}
 		}
 		if len(contents) != 0 {
-			t.Fatalf("not all files were found: missing %v", contents)
+			t.Errorf("not all files were found: missing %v", contents)
 		}
 	})
 }
@@ -475,3 +485,163 @@
 func (l *testLogger) Printf(format string, v ...interface{}) {
 	l.t.Logf("task %-10v: LOG: %s", l.task, fmt.Sprintf(format, v...))
 }
+
+// fakeSign acts like a human running the signbinaries job periodically.
+func fakeSign(ctx context.Context, t *testing.T, dir string) {
+	seen := map[string]bool{}
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-time.After(500 * time.Millisecond):
+		}
+
+		fakeSignOnce(t, dir, seen)
+	}
+}
+
+func fakeSignOnce(t *testing.T, dir string, seen map[string]bool) {
+	contents, err := os.ReadDir(dir)
+	if os.IsNotExist(err) {
+		return
+	}
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, fi := range contents {
+		fn := fi.Name()
+		if fn == "signed" || seen[fn] {
+			continue
+		}
+		var copy, gpgSign, makePkg bool
+		hasSuffix := func(suffix string) bool { return strings.HasSuffix(fn, suffix) }
+		switch {
+		case strings.Contains(fn, "darwin") && hasSuffix(".tar.gz"):
+			copy = true
+			gpgSign = true
+			makePkg = true
+		case strings.Contains(fn, "darwin") && hasSuffix(".pkg"):
+			copy = true
+		case hasSuffix(".tar.gz"):
+			gpgSign = true
+		case hasSuffix("msi"):
+			copy = true
+		}
+
+		if err := os.MkdirAll(filepath.Join(dir, "signed"), 0777); err != nil {
+			t.Fatal(err)
+		}
+
+		writeSignedWithHash := func(filename string, contents []byte) {
+			path := filepath.Join(dir, "signed", filename)
+			if err := ioutil.WriteFile(path, contents, 0777); err != nil {
+				t.Fatal(err)
+			}
+			hash := fmt.Sprintf("%x", sha256.Sum256(contents))
+			if err := ioutil.WriteFile(path+".sha256", []byte(hash), 0777); err != nil {
+				t.Fatal(err)
+			}
+		}
+
+		if copy {
+			bytes, err := ioutil.ReadFile(filepath.Join(dir, fn))
+			if err != nil {
+				t.Fatal(err)
+			}
+			writeSignedWithHash(fn, bytes)
+		}
+		if makePkg {
+			writeSignedWithHash(strings.ReplaceAll(fn, ".tar.gz", ".pkg"), []byte("I'm a .pkg!\n"))
+		}
+		if gpgSign {
+			writeSignedWithHash(fn+".asc", []byte("gpg signature"))
+		}
+		seen[fn] = true
+	}
+}
+
+// These are the files created by the Go 1.18 release.
+const inputs = `
+go1.18.darwin-amd64.tar.gz
+go1.18.darwin-arm64.tar.gz
+go1.18.freebsd-386.tar.gz
+go1.18.freebsd-amd64.tar.gz
+go1.18.linux-386.tar.gz
+go1.18.linux-amd64.tar.gz
+go1.18.linux-arm64.tar.gz
+go1.18.linux-armv6l.tar.gz
+go1.18.linux-ppc64le.tar.gz
+go1.18.linux-s390x.tar.gz
+go1.18.src.tar.gz
+go1.18.windows-386.msi
+go1.18.windows-386.zip
+go1.18.windows-amd64.msi
+go1.18.windows-amd64.zip
+go1.18.windows-arm64.msi
+go1.18.windows-arm64.zip
+`
+
+// These are the files created in the "signed" folder by the signing run for Go 1.18.
+const outputs = `
+go1.18.darwin-amd64.pkg
+go1.18.darwin-amd64.pkg.sha256
+go1.18.darwin-amd64.tar.gz
+go1.18.darwin-amd64.tar.gz.asc
+go1.18.darwin-amd64.tar.gz.asc.sha256
+go1.18.darwin-amd64.tar.gz.sha256
+go1.18.darwin-arm64.pkg
+go1.18.darwin-arm64.pkg.sha256
+go1.18.darwin-arm64.tar.gz
+go1.18.darwin-arm64.tar.gz.asc
+go1.18.darwin-arm64.tar.gz.asc.sha256
+go1.18.darwin-arm64.tar.gz.sha256
+go1.18.freebsd-386.tar.gz.asc
+go1.18.freebsd-386.tar.gz.asc.sha256
+go1.18.freebsd-amd64.tar.gz.asc
+go1.18.freebsd-amd64.tar.gz.asc.sha256
+go1.18.linux-386.tar.gz.asc
+go1.18.linux-386.tar.gz.asc.sha256
+go1.18.linux-amd64.tar.gz.asc
+go1.18.linux-amd64.tar.gz.asc.sha256
+go1.18.linux-arm64.tar.gz.asc
+go1.18.linux-arm64.tar.gz.asc.sha256
+go1.18.linux-armv6l.tar.gz.asc
+go1.18.linux-armv6l.tar.gz.asc.sha256
+go1.18.linux-ppc64le.tar.gz.asc
+go1.18.linux-ppc64le.tar.gz.asc.sha256
+go1.18.linux-s390x.tar.gz.asc
+go1.18.linux-s390x.tar.gz.asc.sha256
+go1.18.src.tar.gz.asc
+go1.18.src.tar.gz.asc.sha256
+go1.18.windows-386.msi
+go1.18.windows-386.msi.sha256
+go1.18.windows-amd64.msi
+go1.18.windows-amd64.msi.sha256
+go1.18.windows-arm64.msi
+go1.18.windows-arm64.msi.sha256
+`
+
+func TestFakeSign(t *testing.T) {
+	dir := t.TempDir()
+	for _, f := range strings.Split(strings.TrimSpace(inputs), "\n") {
+		if err := ioutil.WriteFile(filepath.Join(dir, f), []byte("hi"), 0777); err != nil {
+			t.Fatal(err)
+		}
+	}
+	fakeSignOnce(t, dir, map[string]bool{})
+	want := map[string]bool{}
+	for _, f := range strings.Split(strings.TrimSpace(outputs), "\n") {
+		want[f] = true
+	}
+	got := map[string]bool{}
+	files, err := ioutil.ReadDir(filepath.Join(dir, "signed"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, f := range files {
+		got[f.Name()] = true
+	}
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("signed outputs mismatch (-want +got):\n%v", diff)
+	}
+}
diff --git a/internal/relui/workflows.go b/internal/relui/workflows.go
index f1bffb2..620cb1a 100644
--- a/internal/relui/workflows.go
+++ b/internal/relui/workflows.go
@@ -8,10 +8,12 @@
 	"crypto/sha256"
 	"fmt"
 	"io"
+	"io/fs"
 	"math/rand"
 	"path"
 	"strings"
 	"sync"
+	"time"
 
 	"cloud.google.com/go/storage"
 	"golang.org/x/build/buildlet"
@@ -208,6 +210,7 @@
 	source := wd.Task("Build source archive", tasks.buildSource, revision, version)
 	// Artifact file paths.
 	artifacts := []workflow.Value{source}
+	var darwinTargets []*releasetargets.Target
 	// Empty values that represent the dependency on tests passing.
 	var testResults []workflow.Value
 	for _, target := range targets {
@@ -216,11 +219,15 @@
 
 		// Build release artifacts for the platform.
 		bin := wd.Task(taskName("Build binary archive"), tasks.buildBinary, targetVal, source)
-		if target.GOOS == "windows" {
+		switch target.GOOS {
+		case "windows":
 			zip := wd.Task(taskName("Convert to .zip"), tasks.convertToZip, targetVal, bin)
 			msi := wd.Task(taskName("Build MSI"), tasks.buildMSI, targetVal, bin)
 			artifacts = append(artifacts, msi, zip)
-		} else {
+		case "darwin":
+			artifacts = append(artifacts, bin)
+			darwinTargets = append(darwinTargets, target)
+		default:
 			artifacts = append(artifacts, bin)
 		}
 
@@ -234,10 +241,9 @@
 			testResults = append(testResults, long)
 		}
 	}
-	// Eventually we need to sign artifacts and perhaps summarize test results.
-	// For now, just mush them all together.
 	stagedArtifacts := wd.Task("Stage artifacts for signing", tasks.copyToStaging, version, wd.Slice(artifacts))
-	wd.Output("Staged artifacts", stagedArtifacts)
+	signedArtifacts := wd.Task("Wait for signed artifacts", tasks.awaitSigned, version, wd.Constant(darwinTargets), stagedArtifacts)
+	wd.Output("Signed artifacts", signedArtifacts)
 	results := wd.Task("Combine results", combineResults, stagedArtifacts, wd.Slice(testResults))
 	wd.Output("Build results", results)
 	return wd, nil
@@ -397,6 +403,11 @@
 	scratchPath string
 	// The path the artifact was staged to for the signing process.
 	stagingPath string
+	// The path artifact can be found at after the signing process. It may be
+	// the same as the staging path for artifacts that are externally signed.
+	signedPath string
+	// The contents of the GPG signature for this artifact (.asc file).
+	gpgSignature string
 	// The filename suffix of the artifact, e.g. "tar.gz" or "src.tar.gz",
 	// combined with the version and target name to produce filename.
 	suffix string
@@ -459,3 +470,117 @@
 	}
 	return stagedArtifacts, nil
 }
+
+var signingPollDuration = 30 * time.Second
+
+// awaitSigned waits for all of artifacts to be signed, plus the pkgs for
+// darwinTargets.
+func (tasks *BuildReleaseTasks) awaitSigned(ctx *workflow.TaskContext, version string, darwinTargets []*releasetargets.Target, artifacts []artifact) ([]artifact, error) {
+	// .pkg artifacts are created by the signing process. Create placeholders,
+	// to be filled out once the files exist.
+	for _, t := range darwinTargets {
+		artifacts = append(artifacts, artifact{
+			target:   t,
+			suffix:   "pkg",
+			filename: version + "." + t.Name + ".pkg",
+			size:     -1,
+		})
+	}
+
+	stagingFS, err := gcsfs.FromURL(ctx, tasks.GCSClient, tasks.StagingURL)
+	if err != nil {
+		return nil, err
+	}
+
+	todo := map[artifact]bool{}
+	for _, a := range artifacts {
+		todo[a] = true
+	}
+	var signedArtifacts []artifact
+	for {
+		for a := range todo {
+			signed, ok, err := readSignedArtifact(stagingFS, version, a)
+			if err != nil {
+				return nil, err
+			}
+			if !ok {
+				continue
+			}
+
+			signedArtifacts = append(signedArtifacts, signed)
+			delete(todo, a)
+		}
+
+		if len(todo) == 0 {
+			return signedArtifacts, nil
+		}
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-time.After(signingPollDuration):
+			ctx.Printf("Still waiting for %v artifacts to be signed", len(todo))
+		}
+	}
+}
+
+func readSignedArtifact(stagingFS fs.FS, version string, a artifact) (_ artifact, ok bool, _ error) {
+	// Our signing process has somewhat uneven behavior. In general, for things
+	// that contain their own signature, such as MSIs and .pkgs, we don't
+	// produce a GPG signature, just the new file. On macOS, tars can be signed
+	// too, but we GPG sign them anyway.
+	modifiedBySigning := false
+	hasGPG := false
+	suffix := func(suffix string) bool { return a.suffix == suffix }
+	switch {
+	case suffix("src.tar.gz"):
+		hasGPG = true
+	case a.target.GOOS == "darwin" && suffix("tar.gz"):
+		modifiedBySigning = true
+		hasGPG = true
+	case a.target.GOOS == "darwin" && suffix("pkg"):
+		modifiedBySigning = true
+	case suffix("tar.gz"):
+		hasGPG = true
+	case suffix("msi"):
+		modifiedBySigning = true
+	case suffix("zip"):
+		// For reasons unclear, we don't sign zip files.
+	default:
+		return artifact{}, false, fmt.Errorf("unhandled file type %q", a.suffix)
+	}
+
+	signed := artifact{
+		target:   a.target,
+		filename: a.filename,
+		suffix:   a.suffix,
+	}
+	if modifiedBySigning {
+		signed.signedPath = version + "/signed/" + a.filename
+	} else {
+		signed.signedPath = version + "/" + a.filename
+	}
+
+	fi, err := fs.Stat(stagingFS, signed.signedPath)
+	if err != nil {
+		return artifact{}, false, nil
+	}
+	if modifiedBySigning {
+		hash, err := fs.ReadFile(stagingFS, version+"/signed/"+a.filename+".sha256")
+		if err != nil {
+			return artifact{}, false, err
+		}
+		signed.size = int(fi.Size())
+		signed.sha256 = string(hash)
+	} else {
+		signed.sha256 = a.sha256
+		signed.size = a.size
+	}
+	if hasGPG {
+		sig, err := fs.ReadFile(stagingFS, version+"/signed/"+a.filename+".asc")
+		if err != nil {
+			return artifact{}, false, nil
+		}
+		signed.gpgSignature = string(sig)
+	}
+	return signed, true, nil
+}