blob: d59e07f837f9164b51e528403df00eff056096ee [file] [log] [blame]
// 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 relui
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/flate"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"path"
"regexp"
"slices"
"sort"
"strings"
"sync"
"time"
"cloud.google.com/go/storage"
"github.com/google/go-cmp/cmp"
pb "go.chromium.org/luci/buildbucket/proto"
"golang.org/x/build/dashboard"
"golang.org/x/build/internal/gcsfs"
"golang.org/x/build/internal/installer/darwinpkg"
"golang.org/x/build/internal/installer/windowsmsi"
"golang.org/x/build/internal/releasetargets"
"golang.org/x/build/internal/relui/db"
"golang.org/x/build/internal/relui/groups"
"golang.org/x/build/internal/relui/sign"
"golang.org/x/build/internal/task"
"golang.org/x/build/internal/workflow"
wf "golang.org/x/build/internal/workflow"
"golang.org/x/exp/maps"
"golang.org/x/net/context/ctxhttp"
"google.golang.org/protobuf/types/known/structpb"
)
// DefinitionHolder holds workflow definitions.
type DefinitionHolder struct {
mu sync.Mutex
definitions map[string]*wf.Definition
}
// NewDefinitionHolder creates a new DefinitionHolder,
// initialized with a sample "echo" wf.
func NewDefinitionHolder() *DefinitionHolder {
return &DefinitionHolder{definitions: map[string]*wf.Definition{
"echo": newEchoWorkflow(),
}}
}
// Definition returns the initialized wf.Definition registered
// for a given name.
func (h *DefinitionHolder) Definition(name string) *wf.Definition {
h.mu.Lock()
defer h.mu.Unlock()
return h.definitions[name]
}
// RegisterDefinition registers a definition with a name.
// If a definition with the same name already exists, RegisterDefinition panics.
func (h *DefinitionHolder) RegisterDefinition(name string, d *wf.Definition) {
h.mu.Lock()
defer h.mu.Unlock()
if _, exist := h.definitions[name]; exist {
panic("relui: multiple registrations for " + name)
}
h.definitions[name] = d
}
// Definitions returns the names of all registered definitions.
func (h *DefinitionHolder) Definitions() map[string]*wf.Definition {
h.mu.Lock()
defer h.mu.Unlock()
defs := make(map[string]*wf.Definition)
for k, v := range h.definitions {
defs[k] = v
}
return defs
}
// Release parameter definitions.
var (
targetDateParam = wf.ParamDef[task.Date]{
Name: "Target Release Date",
ParamType: wf.ParamType[task.Date]{
HTMLElement: "input",
HTMLInputType: "date",
},
Doc: `Target Release Date is the date on which the release is scheduled.
It must be three to seven days after the pre-announcement as documented in the security policy.`,
}
securityPreAnnParam = wf.ParamDef[string]{
Name: "Security Content",
ParamType: workflow.ParamType[string]{
HTMLElement: "select",
HTMLSelectOptions: []string{
"the standard library",
"the toolchain",
"the standard library and the toolchain",
},
},
Doc: `Security Content is the security content to be included in the release pre-announcement.
It must not reveal details beyond what's allowed by the security policy.`,
}
securityPreAnnCVEsParam = wf.ParamDef[[]string]{
Name: "PRIVATE-track CVEs",
ParamType: wf.SliceShort,
Example: "CVE-2023-XXXX",
Doc: "List of CVEs for PRIVATE track fixes contained in the release to be included in the pre-announcement.",
Check: func(cves []string) error {
var m = make(map[string]bool)
for _, c := range cves {
switch {
case !cveRE.MatchString(c):
return fmt.Errorf("CVE ID %q doesn't match %s", c, cveRE)
case m[c]:
return fmt.Errorf("duplicate CVE ID %q", c)
}
m[c] = true
}
return nil
},
}
cveRE = regexp.MustCompile(`^CVE-\d{4}-\d{4,7}$`)
securitySummaryParameter = wf.ParamDef[string]{
Name: "Security Summary (optional)",
Doc: `Security Summary is an optional sentence describing security fixes included in this release.
It shows up in the release tweet.
The empty string means there are no security fixes to highlight.
Past examples:
• "Includes a security fix for crypto/tls (CVE-2021-34558)."
• "Includes a security fix for the Wasm port (CVE-2021-38297)."
• "Includes security fixes for encoding/pem (CVE-2022-24675), crypto/elliptic (CVE-2022-28327), crypto/x509 (CVE-2022-27536)."`,
}
securityFixesParameter = wf.ParamDef[[]string]{
Name: "Security Fixes (optional)",
ParamType: wf.SliceLong,
Doc: `Security Fixes is a list of descriptions, one for each distinct security fix included in this release, in Markdown format.
It shows up in the announcement mail.
The empty list means there are no security fixes included.
Past examples:
• "encoding/pem: fix stack overflow in Decode
A large (more than 5 MB) PEM input can cause a stack overflow in Decode,
leading the program to crash.
Thanks to Juho Nurminen of Mattermost who reported the error.
This is CVE-2022-24675 and Go issue https://go.dev/issue/51853."
• "crypto/elliptic: tolerate all oversized scalars in generic P-256
A crafted scalar input longer than 32 bytes can cause P256().ScalarMult
or P256().ScalarBaseMult to panic. Indirect uses through crypto/ecdsa and
crypto/tls are unaffected. amd64, arm64, ppc64le, and s390x are unaffected.
This was discovered thanks to a Project Wycheproof test vector.
This is CVE-2022-28327 and Go issue https://go.dev/issue/52075."`,
Example: `encoding/pem: fix stack overflow in Decode
A large (more than 5 MB) PEM input can cause a stack overflow in Decode,
leading the program to crash.
Thanks to Juho Nurminen of Mattermost who reported the error.
This is CVE-2022-24675 and Go issue https://go.dev/issue/51853.`,
}
releaseCoordinators = wf.ParamDef[[]string]{
Name: "Release Coordinator Usernames (optional)",
ParamType: wf.SliceShort,
Doc: `Release Coordinator Usernames is an optional list of the coordinators of the release.
Their first names will be included at the end of the release announcement, and CLs will be mailed to them.`,
Example: "heschi",
Check: task.CheckCoordinators,
}
)
// newEchoWorkflow returns a runnable wf.Definition for
// development.
func newEchoWorkflow() *wf.Definition {
wd := wf.New(wf.ACL{})
wf.Output(wd, "greeting", wf.Task1(wd, "greeting", echo, wf.Param(wd, wf.ParamDef[string]{Name: "greeting"})))
wf.Output(wd, "farewell", wf.Task1(wd, "farewell", echo, wf.Param(wd, wf.ParamDef[string]{Name: "farewell"})))
return wd
}
func echo(ctx *wf.TaskContext, arg string) (string, error) {
ctx.Printf("echo(%v, %q)", ctx, arg)
return arg, nil
}
func checkTaskApproved(ctx *wf.TaskContext, p db.PGDBTX) (bool, error) {
q := db.New(p)
t, err := q.Task(ctx, db.TaskParams{
Name: ctx.TaskName,
WorkflowID: ctx.WorkflowID,
})
if !t.ReadyForApproval {
_, err := q.UpdateTaskReadyForApproval(ctx, db.UpdateTaskReadyForApprovalParams{
ReadyForApproval: true,
Name: ctx.TaskName,
WorkflowID: ctx.WorkflowID,
})
if err != nil {
return false, err
}
}
return t.ApprovedAt.Valid, err
}
// ApproveActionDep returns a function for defining approval Actions.
//
// ApproveActionDep takes a single *pgxpool.Pool argument, which is
// used to query the database to determine if a task has been marked
// approved.
//
// ApproveActionDep marks the task as requiring approval in the
// database once the task is started. This can be used to show an
// "approve" control in the UI.
//
// waitAction := wf.ActionN(wd, "Wait for Approval", ApproveActionDep(db), wf.After(someDependency))
func ApproveActionDep(p db.PGDBTX) func(*wf.TaskContext) error {
return func(ctx *wf.TaskContext) error {
_, err := task.AwaitCondition(ctx, 5*time.Second, func() (int, bool, error) {
done, err := checkTaskApproved(ctx, p)
return 0, done, err
})
return err
}
}
// RegisterReleaseWorkflows registers workflows for issuing Go releases.
func RegisterReleaseWorkflows(ctx context.Context, h *DefinitionHolder, build *BuildReleaseTasks, milestone *task.MilestoneTasks, version *task.VersionTasks, comm task.CommunicationTasks) error {
// Register prod release workflows.
if err := registerProdReleaseWorkflows(ctx, h, build, milestone, version, comm); err != nil {
return err
}
// Register pre-announcement workflows.
currentMajor, _, err := version.GetCurrentMajor(ctx)
if err != nil {
return err
}
releases := []struct {
majors []int
}{
{[]int{currentMajor, currentMajor - 1}}, // Both minors.
{[]int{currentMajor}}, // Current minor only.
{[]int{currentMajor - 1}}, // Previous minor only.
}
for _, r := range releases {
wd := wf.New(wf.ACL{Groups: []string{groups.ReleaseTeam}})
versions := wf.Task1(wd, "Get next versions", version.GetNextMinorVersions, wf.Const(r.majors))
targetDate := wf.Param(wd, targetDateParam)
securityContent := wf.Param(wd, securityPreAnnParam)
cves := wf.Param(wd, securityPreAnnCVEsParam)
coordinators := wf.Param(wd, releaseCoordinators)
sentMail := wf.Task5(wd, "mail-pre-announcement", comm.PreAnnounceRelease, versions, targetDate, securityContent, cves, coordinators)
wf.Output(wd, "Pre-announcement URL", wf.Task1(wd, "await-pre-announcement", comm.AwaitAnnounceMail, sentMail))
var names []string
for _, m := range r.majors {
names = append(names, fmt.Sprintf("1.%d", m))
}
h.RegisterDefinition("pre-announce next minor release for Go "+strings.Join(names, " and "), wd)
}
// Register workflows for miscellaneous tasks that happen as part of the Go release cycle.
{
// Register a "ping early-in-cycle issues" workflow.
wd := wf.New(wf.ACL{Groups: []string{groups.ReleaseTeam}})
openTreeURL := wf.Param(wd, wf.ParamDef[string]{
Name: "Open Tree URL",
Doc: `Open Tree URL is the URL of an announcement that the tree is open for general Go 1.x development.`,
Example: "https://groups.google.com/g/golang-dev/c/09IwUs7cxXA/m/c2jyIhECBQAJ",
Check: func(openTreeURL string) error {
if !strings.HasPrefix(openTreeURL, "https://groups.google.com/g/golang-dev/c/") {
return fmt.Errorf("openTreeURL value %q doesn't begin with the usual prefix, so please double-check that the URL is correct", openTreeURL)
}
return nil
},
})
devVer := wf.Task0(wd, "Get development version", version.GetDevelVersion)
pinged := wf.Task2(wd, "Ping early-in-cycle issues", milestone.PingEarlyIssues, devVer, openTreeURL)
wf.Output(wd, "pinged", pinged)
h.RegisterDefinition("ping early-in-cycle issues in development milestone", wd)
}
{
// Register an "unwait wait-release CLs" workflow.
wd := wf.New(wf.ACL{Groups: []string{groups.ReleaseTeam}})
unwaited := wf.Task0(wd, "Unwait wait-release CLs", version.UnwaitWaitReleaseCLs)
wf.Output(wd, "unwaited", unwaited)
h.RegisterDefinition("unwait wait-release CLs", wd)
}
// Register dry-run release workflows.
registerBuildTestSignOnlyWorkflow(h, version, build, currentMajor+1, task.KindBeta)
return nil
}
func registerProdReleaseWorkflows(ctx context.Context, h *DefinitionHolder, build *BuildReleaseTasks, milestone *task.MilestoneTasks, version *task.VersionTasks, comm task.CommunicationTasks) error {
currentMajor, majorReleaseTime, err := version.GetCurrentMajor(ctx)
if err != nil {
return err
}
type release struct {
major int
kind task.ReleaseKind
suffix string
}
releases := []release{
{currentMajor + 1, task.KindMajor, "final"},
{currentMajor + 1, task.KindRC, "next RC"},
{currentMajor + 1, task.KindBeta, "next beta"},
{currentMajor, task.KindMinor, "next minor"}, // Current minor only.
{currentMajor - 1, task.KindMinor, "next minor"}, // Previous minor only.
}
if time.Since(majorReleaseTime) < 7*24*time.Hour {
releases = append(releases, release{currentMajor, task.KindMajor, "final"})
}
for _, r := range releases {
wd := wf.New(wf.ACL{Groups: []string{groups.ReleaseTeam}})
coordinators := wf.Param(wd, releaseCoordinators)
published := addSingleReleaseWorkflow(build, milestone, version, wd, r.major, r.kind, coordinators)
securitySummary := wf.Const("")
securityFixes := wf.Slice[string]()
if r.kind == task.KindMinor {
securitySummary = wf.Param(wd, securitySummaryParameter)
securityFixes = wf.Param(wd, securityFixesParameter)
}
addCommTasks(wd, build, comm, r.kind, wf.Slice(published), securitySummary, securityFixes, coordinators)
if r.major >= currentMajor {
// Add a task for updating the module proxy test repo that makes sure modules containing go directives
// of the latest published version are fetchable.
wf.Task1(wd, "update-proxy-test", version.UpdateProxyTestRepo, published)
}
h.RegisterDefinition(fmt.Sprintf("Go 1.%d %s", r.major, r.suffix), wd)
}
wd, err := createMinorReleaseWorkflow(build, milestone, version, comm, currentMajor-1, currentMajor)
if err != nil {
return err
}
h.RegisterDefinition(fmt.Sprintf("Minor releases for Go 1.%d and 1.%d", currentMajor-1, currentMajor), wd)
return nil
}
func registerBuildTestSignOnlyWorkflow(h *DefinitionHolder, version *task.VersionTasks, build *BuildReleaseTasks, major int, kind task.ReleaseKind) {
wd := wf.New(wf.ACL{Groups: []string{groups.ReleaseTeam}})
nextVersion := wf.Task2(wd, "Get next version", version.GetNextVersion, wf.Const(major), wf.Const(kind))
branch := fmt.Sprintf("release-branch.go1.%d", major)
if kind == task.KindBeta {
branch = "master"
}
branchVal := wf.Const(branch)
timestamp := wf.Task0(wd, "Timestamp release", now)
versionFile := wf.Task2(wd, "Generate VERSION file", version.GenerateVersionFile, nextVersion, timestamp)
wf.Output(wd, "VERSION file", versionFile)
head := wf.Task1(wd, "Read branch head", version.ReadBranchHead, branchVal)
srcSpec := wf.Task4(wd, "Select source spec", build.getGitSource, branchVal, head, wf.Const(""), versionFile)
source, artifacts, mods := build.addBuildTasks(wd, major, kind, nextVersion, timestamp, srcSpec)
wf.Output(wd, "Source", source)
wf.Output(wd, "Artifacts", artifacts)
wf.Output(wd, "Modules", mods)
h.RegisterDefinition(fmt.Sprintf("dry-run (build, test, and sign only): Go 1.%d next beta", major), wd)
}
func createMinorReleaseWorkflow(build *BuildReleaseTasks, milestone *task.MilestoneTasks, version *task.VersionTasks, comm task.CommunicationTasks, prevMajor, currentMajor int) (*wf.Definition, error) {
wd := wf.New(wf.ACL{Groups: []string{groups.ReleaseTeam}})
coordinators := wf.Param(wd, releaseCoordinators)
currPublished := addSingleReleaseWorkflow(build, milestone, version, wd.Sub(fmt.Sprintf("Go 1.%d", currentMajor)), currentMajor, task.KindMinor, coordinators)
prevPublished := addSingleReleaseWorkflow(build, milestone, version, wd.Sub(fmt.Sprintf("Go 1.%d", prevMajor)), prevMajor, task.KindMinor, coordinators)
securitySummary := wf.Param(wd, securitySummaryParameter)
securityFixes := wf.Param(wd, securityFixesParameter)
addCommTasks(wd, build, comm, task.KindMinor, wf.Slice(currPublished, prevPublished), securitySummary, securityFixes, coordinators)
wf.Task1(wd, "update-proxy-test", version.UpdateProxyTestRepo, currPublished)
return wd, nil
}
func addCommTasks(
wd *wf.Definition, build *BuildReleaseTasks, comm task.CommunicationTasks,
kind task.ReleaseKind, published wf.Value[[]task.Published], securitySummary wf.Value[string], securityFixes, coordinators wf.Value[[]string],
) {
okayToAnnounce := wf.Action0(wd, "Wait to Announce", build.ApproveAction, wf.After(published))
// Announce that a new Go release has been published.
sentMail := wf.Task4(wd, "mail-announcement", comm.AnnounceRelease, wf.Const(kind), published, securityFixes, coordinators, wf.After(okayToAnnounce))
announcementURL := wf.Task1(wd, "await-announcement", comm.AwaitAnnounceMail, sentMail)
tweetURL := wf.Task4(wd, "post-tweet", comm.TweetRelease, wf.Const(kind), published, securitySummary, announcementURL, wf.After(okayToAnnounce))
mastodonURL := wf.Task4(wd, "post-mastodon", comm.TrumpetRelease, wf.Const(kind), published, securitySummary, announcementURL, wf.After(okayToAnnounce))
wf.Output(wd, "Announcement URL", announcementURL)
wf.Output(wd, "Tweet URL", tweetURL)
wf.Output(wd, "Mastodon URL", mastodonURL)
}
func now(_ context.Context) (time.Time, error) {
return time.Now().UTC().Round(time.Second), nil
}
func addSingleReleaseWorkflow(
build *BuildReleaseTasks, milestone *task.MilestoneTasks, version *task.VersionTasks,
wd *wf.Definition, major int, kind task.ReleaseKind, coordinators wf.Value[[]string],
) wf.Value[task.Published] {
kindVal := wf.Const(kind)
branch := fmt.Sprintf("release-branch.go1.%d", major)
if kind == task.KindBeta {
branch = "master"
}
branchVal := wf.Const(branch)
startingHead := wf.Task1(wd, "Read starting branch head", version.ReadBranchHead, branchVal)
// Select version, check milestones.
nextVersion := wf.Task2(wd, "Get next version", version.GetNextVersion, wf.Const(major), kindVal)
timestamp := wf.Task0(wd, "Timestamp release", now)
versionFile := wf.Task2(wd, "Generate VERSION file", version.GenerateVersionFile, nextVersion, timestamp)
wf.Output(wd, "VERSION file", versionFile)
milestones := wf.Task2(wd, "Pick milestones", milestone.FetchMilestones, nextVersion, kindVal)
checked := wf.Action3(wd, "Check blocking issues", milestone.CheckBlockers, milestones, nextVersion, kindVal)
securityRef := wf.Param(wd, wf.ParamDef[string]{
Name: "Ref from the private repository to build from (optional)",
Doc: `This optional parameter controls where to build from.
The default workflow behavior, if this value is the empty string,
is to build from the head of the corresponding release branch
in the public Go repository (go.googlesource.com/go).
This is intended for releases with no PRIVATE-track security fixes.
If a non-empty string is entered, it must correspond to a ref
in the private repository (go-internal.googlesource.com/go).
The ref can be a branch name (e.g., "private-release-branch.go1.23.4")
or a commit hash (e.g., "8890e8372e12d3b595e0e8fec29f8d7783ab2daf").
This is intended for releases with 1+ PRIVATE-track security fixes.`,
})
securityCommit := wf.Task1(wd, "Read security ref", build.readSecurityRef, securityRef)
srcSpec := wf.Task4(wd, "Select source spec", build.getGitSource, branchVal, startingHead, securityCommit, versionFile, wf.After(checked))
// Build, test, and sign release.
source, signedAndTestedArtifacts, modules := build.addBuildTasks(wd, major, kind, nextVersion, timestamp, srcSpec)
okayToTagAndPublish := wf.Action0(wd, "Wait for Release Coordinator Approval", build.ApproveAction, wf.After(signedAndTestedArtifacts))
dlcl := wf.Task5(wd, "Mail DL CL", version.MailDLCL, wf.Const(major), kindVal, nextVersion, coordinators, wf.Const(false), wf.After(okayToTagAndPublish))
dlclCommit := wf.Task2(wd, "Wait for DL CL submission", version.AwaitCL, dlcl, wf.Const(""))
wf.Output(wd, "Download CL submitted", dlclCommit)
// Tag version and upload to CDN/website.
// If we're releasing a beta from master, tagging is easy; we just tag the
// commit we started from. Otherwise, we're going to submit a VERSION CL,
// and we need to make sure that that CL is submitted on top of the same
// state we built from. For security releases that state may not have
// been public when we started, but it should be now.
tagCommit := startingHead
if branch != "master" {
publishingHead := wf.Task3(wd, "Check branch state matches source archive", build.checkSourceMatch, branchVal, versionFile, source, wf.After(okayToTagAndPublish))
versionCL := wf.Task4(wd, "Mail version CL", version.CreateAutoSubmitVersionCL, branchVal, nextVersion, coordinators, versionFile, wf.After(publishingHead))
tagCommit = wf.Task2(wd, "Wait for version CL submission", version.AwaitCL, versionCL, publishingHead)
}
tagged := wf.Action2(wd, "Tag version", version.TagRelease, nextVersion, tagCommit, wf.After(okayToTagAndPublish))
uploaded := wf.Action1(wd, "Upload artifacts to CDN", build.uploadArtifacts, signedAndTestedArtifacts, wf.After(tagged))
uploadedMods := wf.Action2(wd, "Upload modules to CDN", build.uploadModules, nextVersion, modules, wf.After(tagged))
availableOnProxy := wf.Action2(wd, "Wait for modules on proxy.golang.org", build.awaitProxy, nextVersion, modules, wf.After(uploadedMods))
pushed := wf.Action3(wd, "Push issues", milestone.PushIssues, milestones, nextVersion, kindVal, wf.After(tagged))
published := wf.Task2(wd, "Publish to website", build.publishArtifacts, nextVersion, signedAndTestedArtifacts, wf.After(uploaded, availableOnProxy, pushed))
if kind == task.KindMajor {
xToolsStdlibCL := wf.Task2(wd, fmt.Sprintf("Mail x/tools stdlib CL for 1.%d", major), version.CreateUpdateStdlibIndexCL, coordinators, nextVersion, wf.After(published))
xToolsStdlibCommit := wf.Task2(wd, "Wait for x/tools stdlib CL submission", version.AwaitCL, xToolsStdlibCL, wf.Const(""))
wf.Output(wd, "x/tools stdlib CL submitted", xToolsStdlibCommit)
}
dockerBuild := wf.Task1(wd, "Start Google Docker build", build.runGoogleDockerBuild, nextVersion, wf.After(uploaded))
dockerResult := wf.Task1(wd, "Await Google Docker build", build.awaitCloudBuild, dockerBuild)
wf.Output(wd, "Google Docker image status", dockerResult)
wf.Output(wd, "Published to website", published)
return published
}
// sourceSpec encapsulates all the information that describes a source archive.
type sourceSpec struct {
GitilesURL, Project, Branch, Revision string
VersionFile string
}
func (s *sourceSpec) ArchiveURL() string {
return fmt.Sprintf("%s/%s/+archive/%s.tar.gz", s.GitilesURL, s.Project, s.Revision)
}
type moduleArtifact struct {
// The target for this module.
Target *releasetargets.Target
// The contents of the mod and info files.
Mod, Info string
// The scratch path of the zip within the scratch directory.
ZipScratch string // scratch path
}
// addBuildTasks registers tasks to build, test, and sign the release onto wd.
// It returns the resulting artifacts of various kinds.
func (tasks *BuildReleaseTasks) addBuildTasks(wd *wf.Definition, major int, kind task.ReleaseKind, version wf.Value[string], timestamp wf.Value[time.Time], sourceSpec wf.Value[sourceSpec]) (wf.Value[artifact], wf.Value[[]artifact], wf.Value[[]moduleArtifact]) {
targets := releasetargets.TargetsForGo1Point(major)
skipTests := wf.Param(wd, wf.ParamDef[[]string]{Name: "Targets to skip testing (or 'all') (optional)", ParamType: wf.SliceShort})
source := wf.Task1(wd, "Build source archive", tasks.buildSource, sourceSpec)
artifacts := []wf.Value[artifact]{source}
var mods []wf.Value[moduleArtifact]
var blockers []wf.Dependency
// Build and sign binary artifacts for all targets.
for _, target := range targets {
wd := wd.Sub(target.Name)
// Build release artifacts for the platform using make.bash -distpack.
// For windows, produce both a tgz and zip -- we need tgzs to run
// tests, even though we'll eventually publish the zips.
var tar, zip wf.Value[artifact]
var mod wf.Value[moduleArtifact]
{ // Block to improve diff readability. Can be unnested later.
distpack := wf.Task2(wd, "Build distpack", tasks.buildDistpack, wf.Const(target), source)
reproducer := wf.Task2(wd, "Reproduce distpack on Windows", tasks.reproduceDistpack, wf.Const(target), source)
match := wf.Action2(wd, "Check distpacks match", tasks.checkDistpacksMatch, distpack, reproducer)
blockers = append(blockers, match)
if target.GOOS == "windows" {
zip = wf.Task1(wd, "Get binary from distpack", tasks.binaryArchiveFromDistpack, distpack)
tar = wf.Task1(wd, "Convert zip to .tgz", tasks.convertZipToTGZ, zip)
} else {
tar = wf.Task1(wd, "Get binary from distpack", tasks.binaryArchiveFromDistpack, distpack)
}
mod = wf.Task1(wd, "Get module files from distpack", tasks.modFilesFromDistpack, distpack)
}
// Create installers and perform platform-specific signing where
// applicable. For macOS, produce updated tgz and module zips that
// include the signed binaries.
switch target.GOOS {
case "darwin":
pkg := wf.Task1(wd, "Build PKG installer", tasks.buildDarwinPKG, tar)
signedPKG := wf.Task2(wd, "Sign PKG installer", tasks.signArtifact, pkg, wf.Const(sign.BuildMacOS))
signedTGZ := wf.Task2(wd, "Merge signed files into .tgz", tasks.mergeSignedToTGZ, tar, signedPKG)
mod = wf.Task4(wd, "Merge signed files into module zip", tasks.mergeSignedToModule, version, timestamp, mod, signedPKG)
artifacts = append(artifacts, signedPKG, signedTGZ)
case "windows":
msi := wf.Task1(wd, "Build MSI installer", tasks.buildWindowsMSI, tar)
signedMSI := wf.Task2(wd, "Sign MSI installer", tasks.signArtifact, msi, wf.Const(sign.BuildWindows))
artifacts = append(artifacts, signedMSI, zip)
default:
artifacts = append(artifacts, tar)
}
mods = append(mods, mod)
}
signedArtifacts := wf.Task1(wd, "Compute GPG signature for artifacts", tasks.computeGPG, wf.Slice(artifacts...))
// Test all targets.
builders := wf.Task2(wd, "Read builders", tasks.readRelevantBuilders, wf.Const(major), wf.Const(kind))
builderResults := wf.Expand1(wd, "Plan builders", func(wd *wf.Definition, builders []string) (wf.Value[[]testResult], error) {
var results []wf.Value[testResult]
for _, b := range builders {
// Note: We can consider adding an "is_first_class" property into builder config
// and using it to display whether the builder is for a first class port or not.
// Until then, it's up to the release coordinator to make this distintinction when
// approving any failures.
res := wf.Task3(wd, "Run advisory builder "+b, tasks.runAdvisoryBuildBucket, wf.Const(b), skipTests, sourceSpec)
results = append(results, res)
}
return wf.Slice(results...), nil
}, builders)
buildersApproved := wf.Action1(wd, "Wait for advisory builders", tasks.checkTestResults, builderResults)
blockers = append(blockers, buildersApproved)
signedAndTested := wf.Task2(wd, "Wait for signing and tests", func(ctx *wf.TaskContext, artifacts []artifact, version string) ([]artifact, error) {
// Note: Note this needs to happen somewhere, doesn't matter where. Maybe move it to a nicer place later.
for i, a := range artifacts {
if a.Target != nil {
artifacts[i].Filename = version + "." + a.Target.Name + "." + a.Suffix
} else {
artifacts[i].Filename = version + "." + a.Suffix
}
}
return artifacts, nil
}, signedArtifacts, version, wf.After(blockers...), wf.After(wf.Slice(mods...)))
return source, signedAndTested, wf.Slice(mods...)
}
// BuildReleaseTasks serves as an adapter to the various build tasks in the task package.
type BuildReleaseTasks struct {
GerritClient task.GerritClient
GerritProject string
GerritHTTPClient *http.Client // GerritHTTPClient is an HTTP client that authenticates to Gerrit instances. (Both public and private.)
PrivateGerritClient task.GerritClient
PrivateGerritProject string
GCSClient *storage.Client
ScratchFS *task.ScratchFS
SignedURL string // SignedURL is a gs:// or file:// URL, no trailing slash.
ServingURL string // ServingURL is a gs:// or file:// URL, no trailing slash.
DownloadURL string
ProxyPrefix string // ProxyPrefix is the prefix at which module files are published, e.g. https://proxy.golang.org/golang.org/toolchain/@v
PublishFile func(task.WebsiteFile) error
SignService sign.Service
GoogleDockerBuildProject string
GoogleDockerBuildTrigger string
CloudBuildClient task.CloudBuildClient
BuildBucketClient task.BuildBucketClient
SwarmingClient task.SwarmingClient
ApproveAction func(*wf.TaskContext) error
}
var commitRE = regexp.MustCompile(`[a-f0-9]{40}`)
func (b *BuildReleaseTasks) readSecurityRef(ctx *wf.TaskContext, ref string) (string, error) {
if ref == "" {
return "", nil
}
if commitRE.MatchString(ref) {
return ref, nil
}
commit, err := b.PrivateGerritClient.ReadBranchHead(ctx, b.PrivateGerritProject, ref)
if err != nil {
return "", fmt.Errorf("%q doesn't appear to be a commit hash, but resolving it as a branch failed: %v", ref, err)
}
return commit, nil
}
func (b *BuildReleaseTasks) getGitSource(ctx *wf.TaskContext, branch, commit, securityCommit, versionFile string) (sourceSpec, error) {
client, project, rev := b.GerritClient, b.GerritProject, commit
if securityCommit != "" {
client, project, rev = b.PrivateGerritClient, b.PrivateGerritProject, securityCommit
}
return sourceSpec{
GitilesURL: client.GitilesURL(),
Project: project,
Branch: branch,
Revision: rev,
VersionFile: versionFile,
}, nil
}
func (b *BuildReleaseTasks) buildSource(ctx *wf.TaskContext, source sourceSpec) (artifact, error) {
resp, err := b.GerritHTTPClient.Get(source.ArchiveURL())
if err != nil {
return artifact{}, err
}
if resp.StatusCode != http.StatusOK {
return artifact{}, fmt.Errorf("failed to fetch %q: %v", source.ArchiveURL(), resp.Status)
}
defer resp.Body.Close()
return b.runBuildStep(ctx, nil, artifact{}, "src.tar.gz", func(_ io.Reader, w io.Writer) error {
return b.buildSourceGCB(ctx, resp.Body, source.VersionFile, w)
})
}
func (b *BuildReleaseTasks) buildSourceGCB(ctx *wf.TaskContext, r io.Reader, versionFile string, w io.Writer) error {
filename, f, err := b.ScratchFS.OpenWrite(ctx, "source.tgz")
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
script := fmt.Sprintf(`
gsutil cp %q source.tgz
mkdir go
tar -xf source.tgz -C go
echo -ne %q > go/VERSION
(cd go/src && GOOS=linux GOARCH=amd64 ./make.bash -distpack)
mv go/pkg/distpack/*.src.tar.gz src.tar.gz
`, b.ScratchFS.URL(ctx, filename), versionFile)
build, err := b.CloudBuildClient.RunScript(ctx, script, "", []string{"src.tar.gz"})
if err != nil {
return err
}
if _, err := task.AwaitCondition(ctx, 30*time.Second, func() (string, bool, error) {
return b.CloudBuildClient.Completed(ctx, build)
}); err != nil {
return err
}
resultFS, err := b.CloudBuildClient.ResultFS(ctx, build)
if err != nil {
return err
}
distpack, err := resultFS.Open("src.tar.gz")
if err != nil {
return err
}
_, err = io.Copy(w, distpack)
return err
}
func (b *BuildReleaseTasks) checkSourceMatch(ctx *wf.TaskContext, branch, versionFile string, source artifact) (head string, _ error) {
head, err := b.GerritClient.ReadBranchHead(ctx, b.GerritProject, branch)
if err != nil {
return "", err
}
spec, err := b.getGitSource(ctx, branch, head, "", versionFile)
if err != nil {
return "", err
}
branchArchive, err := b.buildSource(ctx, spec)
if err != nil {
return "", err
}
diff, err := b.diffArtifacts(ctx, branchArchive, source)
if err != nil {
return "", err
}
if diff != "" {
return "", fmt.Errorf("branch state doesn't match source archive (-branch, +archive):\n%v", diff)
}
return head, nil
}
func (b *BuildReleaseTasks) diffArtifacts(ctx *wf.TaskContext, a1, a2 artifact) (string, error) {
h1, err := b.hashArtifact(ctx, a1)
if err != nil {
return "", fmt.Errorf("hashing first tarball: %v", err)
}
h2, err := b.hashArtifact(ctx, a2)
if err != nil {
return "", fmt.Errorf("hashing second tarball: %v", err)
}
return cmp.Diff(h1, h2), nil
}
func (b *BuildReleaseTasks) hashArtifact(ctx *wf.TaskContext, a artifact) (map[string]string, error) {
hashes := map[string]string{}
_, err := b.runBuildStep(ctx, nil, a, "", func(r io.Reader, _ io.Writer) error {
return tarballHashes(r, "", hashes, false)
})
return hashes, err
}
func tarballHashes(r io.Reader, prefix string, hashes map[string]string, includeHeaders bool) error {
gzr, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
} else if err != nil {
return fmt.Errorf("reading tar header: %v", err)
}
if strings.HasSuffix(header.Name, ".tar.gz") {
if err := tarballHashes(tr, header.Name+":", hashes, true); err != nil {
return fmt.Errorf("reading inner tarball %v: %v", header.Name, err)
}
} else {
h := sha256.New()
if _, err := io.CopyN(h, tr, header.Size); err != nil {
return fmt.Errorf("reading file %q: %v", header.Name, err)
}
// At the top level, we don't care about headers, only contents.
// But in inner archives, headers are contents and we care a lot.
if includeHeaders {
hashes[prefix+header.Name] = fmt.Sprintf("%v %X", header, h.Sum(nil))
} else {
hashes[prefix+header.Name] = fmt.Sprintf("%X", h.Sum(nil))
}
}
}
return nil
}
func (b *BuildReleaseTasks) buildDistpack(ctx *wf.TaskContext, target *releasetargets.Target, source artifact) (artifact, error) {
return b.runBuildStep(ctx, target, artifact{}, "tar.gz", func(_ io.Reader, w io.Writer) error {
// We need GOROOT_FINAL both during the binary build and test runs. See go.dev/issue/52236.
// TODO(go.dev/issue/62047): GOROOT_FINAL is being removed. Remove it from here too.
makeEnv := []string{"GOROOT_FINAL=" + dashboard.GorootFinal(target.GOOS)}
// Add extra vars from the target's configuration.
makeEnv = append(makeEnv, target.ExtraEnv...)
makeEnv = append(makeEnv, "GOOS="+target.GOOS, "GOARCH="+target.GOARCH)
script := fmt.Sprintf(`
gsutil cp %q src.tar.gz
tar -xf src.tar.gz
(cd go/src && %v ./make.bash -distpack)
(cd go/pkg/distpack && tar -czf ../../../distpacks.tar.gz *)
`, b.ScratchFS.URL(ctx, source.Scratch), strings.Join(makeEnv, " "))
build, err := b.CloudBuildClient.RunScript(ctx, script, "", []string{"distpacks.tar.gz"})
if err != nil {
return err
}
if _, err := task.AwaitCondition(ctx, 30*time.Second, func() (string, bool, error) {
return b.CloudBuildClient.Completed(ctx, build)
}); err != nil {
return err
}
resultFS, err := b.CloudBuildClient.ResultFS(ctx, build)
if err != nil {
return err
}
distpack, err := resultFS.Open("distpacks.tar.gz")
if err != nil {
return err
}
_, err = io.Copy(w, distpack)
return err
})
}
func (b *BuildReleaseTasks) reproduceDistpack(ctx *wf.TaskContext, target *releasetargets.Target, source artifact) (artifact, error) {
return b.runBuildStep(ctx, target, artifact{}, "tar.gz", func(_ io.Reader, w io.Writer) error {
scratchFile := b.ScratchFS.WriteFilename(ctx, fmt.Sprintf("reproduce-distpack-%v.tar.gz", target.Name))
// This script is carefully crafted to work on both Windows and Unix
// for testing. In particular, Windows doesn't seem to like ./foo.exe,
// so we have to run it unadorned with . on PATH.
script := fmt.Sprintf(
`gsutil cat %s | tar -xzf - && cd go/src && make.bat -distpack && cd ../pkg/distpack && tar -czf - * | gsutil cp - %s`,
b.ScratchFS.URL(ctx, source.Scratch), b.ScratchFS.URL(ctx, scratchFile))
env := map[string]string{
"GOOS": target.GOOS,
"GOARCH": target.GOARCH,
}
for _, e := range target.ExtraEnv {
k, v, ok := strings.Cut(e, "=")
if !ok {
return fmt.Errorf("malformed env var %q", e)
}
env[k] = v
}
id, err := b.SwarmingClient.RunTask(ctx, map[string]string{
"cipd_platform": "windows-amd64",
"os": "Windows-10",
}, script, env)
if err != nil {
return err
}
if _, err := task.AwaitCondition(ctx, 30*time.Second, func() (string, bool, error) {
return b.SwarmingClient.Completed(ctx, id)
}); err != nil {
return err
}
distpack, err := b.ScratchFS.OpenRead(ctx, scratchFile)
if err != nil {
return err
}
_, err = io.Copy(w, distpack)
return err
})
}
func (b *BuildReleaseTasks) checkDistpacksMatch(ctx *wf.TaskContext, linux, windows artifact) error {
diff, err := b.diffArtifacts(ctx, linux, windows)
if err != nil {
return err
}
if diff != "" {
return fmt.Errorf("distpacks don't match (-linux, +windows): %v", diff)
}
return nil
}
func (b *BuildReleaseTasks) binaryArchiveFromDistpack(ctx *wf.TaskContext, distpack artifact) (artifact, error) {
// This must not match the module files, which currently start with v0.0.1.
glob := fmt.Sprintf("go*%v-%v.*", distpack.Target.GOOS, distpack.Target.GOARCH)
suffix := "tar.gz"
if distpack.Target.GOOS == "windows" {
suffix = "zip"
}
return b.runBuildStep(ctx, distpack.Target, distpack, suffix, func(r io.Reader, w io.Writer) error {
return task.ExtractFile(r, w, glob)
})
}
func (b *BuildReleaseTasks) modFilesFromDistpack(ctx *wf.TaskContext, distpack artifact) (moduleArtifact, error) {
result := moduleArtifact{Target: distpack.Target}
artifact, err := b.runBuildStep(ctx, nil, distpack, "mod.zip", func(r io.Reader, w io.Writer) error {
zr, err := gzip.NewReader(r)
if err != nil {
return err
}
tr := tar.NewReader(zr)
foundZip := false
for {
h, err := tr.Next()
if err == io.EOF {
return io.ErrUnexpectedEOF
} else if err != nil {
return err
}
if h.FileInfo().IsDir() || !strings.HasPrefix(h.Name, "v0.0.1") {
continue
}
switch {
case strings.HasSuffix(h.Name, ".zip"):
if _, err := io.Copy(w, tr); err != nil {
return err
}
foundZip = true
case strings.HasSuffix(h.Name, ".info"):
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, tr); err != nil {
return err
}
result.Info = buf.String()
case strings.HasSuffix(h.Name, ".mod"):
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, tr); err != nil {
return err
}
result.Mod = buf.String()
}
if foundZip && result.Mod != "" && result.Info != "" {
return nil
}
}
})
if err != nil {
return moduleArtifact{}, err
}
result.ZipScratch = artifact.Scratch
return result, nil
}
func (b *BuildReleaseTasks) modFilesFromBinary(ctx *wf.TaskContext, version string, t time.Time, tar artifact) (moduleArtifact, error) {
result := moduleArtifact{Target: tar.Target}
a, err := b.runBuildStep(ctx, nil, tar, "mod.zip", func(r io.Reader, w io.Writer) error {
ctx.DisableWatchdog() // The zipping process can be time consuming and is unlikely to hang.
var err error
result.Mod, result.Info, err = task.TarToModFiles(tar.Target, version, t, r, w)
return err
})
if err != nil {
return moduleArtifact{}, err
}
result.ZipScratch = a.Scratch
return result, nil
}
func (b *BuildReleaseTasks) mergeSignedToTGZ(ctx *wf.TaskContext, unsigned, signed artifact) (artifact, error) {
return b.runBuildStep(ctx, unsigned.Target, signed, "tar.gz", func(signed io.Reader, w io.Writer) error {
signedBinaries, err := task.ReadBinariesFromPKG(signed)
if err != nil {
return err
} else if _, ok := signedBinaries["go/bin/go"]; !ok {
return fmt.Errorf("didn't find go/bin/go among %d signed binaries %+q", len(signedBinaries), maps.Keys(signedBinaries))
}
// Copy files from the tgz, overwriting with binaries from the signed tar.
ur, err := b.ScratchFS.OpenRead(ctx, unsigned.Scratch)
if err != nil {
return err
}
defer ur.Close()
uzr, err := gzip.NewReader(ur)
if err != nil {
return err
}
defer uzr.Close()
utr := tar.NewReader(uzr)
zw, err := gzip.NewWriterLevel(w, gzip.BestCompression)
if err != nil {
return err
}
tw := tar.NewWriter(zw)
for {
th, err := utr.Next()
if err == io.EOF {
break
} else if err != nil {
return err
}
hdr := *th
src := io.NopCloser(utr)
if signed, ok := signedBinaries[th.Name]; ok {
src = io.NopCloser(bytes.NewReader(signed))
hdr.Size = int64(len(signed))
}
if err := tw.WriteHeader(&hdr); err != nil {
return err
}
if _, err := io.Copy(tw, src); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return err
}
return zw.Close()
})
}
func (b *BuildReleaseTasks) mergeSignedToModule(ctx *wf.TaskContext, version string, timestamp time.Time, mod moduleArtifact, signed artifact) (moduleArtifact, error) {
a, err := b.runBuildStep(ctx, nil, signed, "signedmod.zip", func(signed io.Reader, w io.Writer) error {
signedBinaries, err := task.ReadBinariesFromPKG(signed)
if err != nil {
return err
} else if _, ok := signedBinaries["go/bin/go"]; !ok {
return fmt.Errorf("didn't find go/bin/go among %d signed binaries %+q", len(signedBinaries), maps.Keys(signedBinaries))
}
// Copy files from the module zip, overwriting with binaries from the signed tar.
mr, err := b.ScratchFS.OpenRead(ctx, mod.ZipScratch)
if err != nil {
return err
}
defer mr.Close()
mbytes, err := io.ReadAll(mr)
if err != nil {
return err
}
mzr, err := zip.NewReader(bytes.NewReader(mbytes), int64(len(mbytes)))
if err != nil {
return err
}
prefix := task.ToolchainZipPrefix(mod.Target, version) + "/"
mzw := zip.NewWriter(w)
mzw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
return flate.NewWriter(out, flate.BestCompression)
})
for _, f := range mzr.File {
var in io.ReadCloser
suffix, ok := strings.CutPrefix(f.Name, prefix)
if !ok {
continue
}
if contents, ok := signedBinaries["go/"+suffix]; ok {
in = io.NopCloser(bytes.NewReader(contents))
} else {
in, err = f.Open()
if err != nil {
return err
}
}
hdr := f.FileHeader
out, err := mzw.CreateHeader(&hdr)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
return err
}
}
return mzw.Close()
})
if err != nil {
return moduleArtifact{}, err
}
mod.ZipScratch = a.Scratch
return mod, nil
}
// buildDarwinPKG constructs an installer for the given binary artifact, to be signed.
func (b *BuildReleaseTasks) buildDarwinPKG(ctx *wf.TaskContext, binary artifact) (artifact, error) {
return b.runBuildStep(ctx, binary.Target, artifact{}, "pkg", func(_ io.Reader, w io.Writer) error {
metadataFile, err := jsonEncodeScratchFile(ctx, b.ScratchFS, darwinpkg.InstallerOptions{
GOARCH: binary.Target.GOARCH,
MinMacOSVersion: binary.Target.MinMacOSVersion,
})
if err != nil {
return err
}
installerPaths, err := b.signArtifacts(ctx, sign.BuildMacOSConstructInstallerOnly, []string{
b.ScratchFS.URL(ctx, binary.Scratch),
b.ScratchFS.URL(ctx, metadataFile),
})
if err != nil {
return err
} else if len(installerPaths) != 1 {
return fmt.Errorf("got %d outputs, want 1 macOS .pkg installer", len(installerPaths))
} else if ext := path.Ext(installerPaths[0]); ext != ".pkg" {
return fmt.Errorf("got output extension %q, want .pkg", ext)
}
resultFS, err := gcsfs.FromURL(ctx, b.GCSClient, b.SignedURL)
if err != nil {
return err
}
r, err := resultFS.Open(installerPaths[0])
if err != nil {
return err
}
defer r.Close()
_, err = io.Copy(w, r)
return err
})
}
// buildWindowsMSI constructs an installer for the given binary artifact, to be signed.
func (b *BuildReleaseTasks) buildWindowsMSI(ctx *wf.TaskContext, binary artifact) (artifact, error) {
return b.runBuildStep(ctx, binary.Target, artifact{}, "msi", func(_ io.Reader, w io.Writer) error {
metadataFile, err := jsonEncodeScratchFile(ctx, b.ScratchFS, windowsmsi.InstallerOptions{
GOARCH: binary.Target.GOARCH,
})
if err != nil {
return err
}
installerPaths, err := b.signArtifacts(ctx, sign.BuildWindowsConstructInstallerOnly, []string{
b.ScratchFS.URL(ctx, binary.Scratch),
b.ScratchFS.URL(ctx, metadataFile),
})
if err != nil {
return err
} else if len(installerPaths) != 1 {
return fmt.Errorf("got %d outputs, want 1 Windows .msi installer", len(installerPaths))
} else if ext := path.Ext(installerPaths[0]); ext != ".msi" {
return fmt.Errorf("got output extension %q, want .msi", ext)
}
resultFS, err := gcsfs.FromURL(ctx, b.GCSClient, b.SignedURL)
if err != nil {
return err
}
r, err := resultFS.Open(installerPaths[0])
if err != nil {
return err
}
defer r.Close()
_, err = io.Copy(w, r)
return err
})
}
func (b *BuildReleaseTasks) convertZipToTGZ(ctx *wf.TaskContext, binary artifact) (artifact, error) {
return b.runBuildStep(ctx, binary.Target, binary, "tar.gz", func(r io.Reader, w io.Writer) error {
// Reading the whole file isn't ideal, but we need a ReaderAt, and
// don't have access to the lower-level file (which would support
// seeking) here.
content, err := io.ReadAll(r)
if err != nil {
return err
}
return task.ConvertZIPToTGZ(bytes.NewReader(content), int64(len(content)), w)
})
}
// computeGPG performs GPG signing on artifacts, and sets their GPGSignature field.
func (b *BuildReleaseTasks) computeGPG(ctx *wf.TaskContext, artifacts []artifact) ([]artifact, error) {
// doGPG reports whether to do GPG signature computation for artifact a.
doGPG := func(a artifact) bool {
return a.Suffix == "src.tar.gz" || a.Suffix == "tar.gz"
}
// Start a signing job on all artifacts that want to do GPG signing and await its results.
var in []string
for _, a := range artifacts {
if !doGPG(a) {
continue
}
in = append(in, b.ScratchFS.URL(ctx, a.Scratch))
}
out, err := b.signArtifacts(ctx, sign.BuildGPG, in)
if err != nil {
return nil, err
} else if len(out) != len(in) {
return nil, fmt.Errorf("got %d outputs, want %d .asc signatures", len(out), len(in))
}
// All done, we have our GPG signatures.
// Put them in a base name → scratch path map.
var signatures = make(map[string]string)
for _, o := range out {
signatures[path.Base(o)] = o
}
// Set the artifacts' GPGSignature field.
signedFS, err := gcsfs.FromURL(ctx, b.GCSClient, b.SignedURL)
if err != nil {
return nil, err
}
for i, a := range artifacts {
if !doGPG(a) {
continue
}
sigPath, ok := signatures[path.Base(a.Scratch)+".asc"]
if !ok {
return nil, fmt.Errorf("no GPG signature for %q", path.Base(a.Scratch))
}
sig, err := fs.ReadFile(signedFS, sigPath)
if err != nil {
return nil, err
}
artifacts[i].GPGSignature = string(sig)
}
return artifacts, nil
}
// signArtifact signs a single artifact of specified type.
func (b *BuildReleaseTasks) signArtifact(ctx *wf.TaskContext, a artifact, bt sign.BuildType) (signed artifact, _ error) {
return b.runBuildStep(ctx, a.Target, artifact{}, a.Suffix, func(_ io.Reader, w io.Writer) error {
signedPaths, err := b.signArtifacts(ctx, bt, []string{b.ScratchFS.URL(ctx, a.Scratch)})
if err != nil {
return err
} else if len(signedPaths) != 1 {
return fmt.Errorf("got %d outputs, want 1 signed artifact", len(signedPaths))
}
signedFS, err := gcsfs.FromURL(ctx, b.GCSClient, b.SignedURL)
if err != nil {
return err
}
r, err := signedFS.Open(signedPaths[0])
if err != nil {
return err
}
_, err = io.Copy(w, r)
return err
})
}
// signArtifacts starts signing on the artifacts provided via the gs:// URL inputs,
// waits for signing to complete, and returns the output paths relative to SignedURL.
func (b *BuildReleaseTasks) signArtifacts(ctx *wf.TaskContext, bt sign.BuildType, inURLs []string) (outFiles []string, _ error) {
jobID, err := b.SignService.SignArtifact(ctx, bt, inURLs)
if err != nil {
return nil, err
}
outURLs, jobError := task.AwaitCondition(ctx, time.Minute, func() (out []string, done bool, _ error) {
statusContext, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
t := time.Now()
status, desc, out, err := b.SignService.ArtifactSigningStatus(statusContext, jobID)
if err != nil {
ctx.Printf("ArtifactSigningStatus ran into a retryable communication error after %v: %v\n", time.Since(t), err)
return nil, false, nil
}
switch status {
case sign.StatusCompleted:
return out, true, nil // All done.
case sign.StatusFailed:
if desc != "" {
return nil, true, fmt.Errorf("signing attempt failed: %s", desc)
}
return nil, true, fmt.Errorf("signing attempt failed")
default:
if desc != "" {
ctx.Printf("still waiting: %s\n", desc)
}
return nil, false, nil // Still waiting.
}
})
if jobError != nil {
// If ctx is canceled, also cancel the signing request.
if ctx.Err() != nil {
cancelContext, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
t := time.Now()
err := b.SignService.CancelSigning(cancelContext, jobID)
if err != nil {
ctx.Printf("CancelSigning error after %v: %v\n", time.Since(t), err)
}
}
return nil, jobError
}
for _, url := range outURLs {
f, ok := strings.CutPrefix(url, b.SignedURL+"/")
if !ok {
return nil, fmt.Errorf("got signed URL %q outside of signing result dir %q, which is unsupported", url, b.SignedURL+"/")
}
outFiles = append(outFiles, f)
}
return outFiles, nil
}
func (b *BuildReleaseTasks) readRelevantBuilders(ctx *wf.TaskContext, major int, kind task.ReleaseKind) ([]string, error) {
prefix := fmt.Sprintf("go1.%v-", major)
if kind == task.KindBeta {
prefix = "gotip-"
}
builders, err := b.BuildBucketClient.ListBuilders(ctx, "security-try")
if err != nil {
return nil, err
}
var relevant []string
for name, b := range builders {
if !strings.HasPrefix(name, prefix) {
continue
}
var props struct {
BuilderMode int `json:"mode"`
KnownIssue int `json:"known_issue"`
}
if err := json.Unmarshal([]byte(b.Properties), &props); err != nil {
return nil, fmt.Errorf("error unmarshaling properties for %v: %v", name, err)
}
var skip []string // Log-worthy causes of skip, if any.
// golangbuildModePerf is golangbuild's MODE_PERF mode that
// runs benchmarks. It's the first custom mode not relevant
// to building and testing, and the expectation is that any
// modes after it will be fine to skip for release purposes.
//
// See https://source.chromium.org/chromium/infra/infra/+/main:go/src/infra/experimental/golangbuild/golangbuildpb/params.proto;l=174-177;drc=fdea4abccf8447808d4e702c8d09fdd20fd81acb.
const golangbuildModePerf = 4
if props.BuilderMode >= golangbuildModePerf {
skip = append(skip, fmt.Sprintf("custom mode %d", props.BuilderMode))
}
if props.KnownIssue != 0 {
skip = append(skip, fmt.Sprintf("known issue %d", props.KnownIssue))
}
if len(skip) != 0 {
ctx.Printf("skipping %s because of %s", name, strings.Join(skip, ", "))
continue
}
relevant = append(relevant, name)
}
slices.Sort(relevant)
return relevant, nil
}
type testResult struct {
Name string
Passed bool
}
func (b *BuildReleaseTasks) runAdvisoryBuildBucket(ctx *wf.TaskContext, name string, skipTests []string, source sourceSpec) (testResult, error) {
return b.runAdvisoryTest(ctx, name, skipTests, func() error {
u, err := url.Parse(source.GitilesURL)
if err != nil {
return err
}
commit := &pb.GitilesCommit{
Host: u.Host,
Project: source.Project,
Id: source.Revision,
Ref: "refs/heads/" + source.Branch,
}
id, err := b.BuildBucketClient.RunBuild(ctx, "security-try", name, commit, map[string]*structpb.Value{
"version_file": structpb.NewStringValue(source.VersionFile),
})
if err != nil {
return err
}
_, err = task.AwaitCondition(ctx, 30*time.Second, func() (string, bool, error) {
return b.BuildBucketClient.Completed(ctx, id)
})
return err
})
}
func (b *BuildReleaseTasks) runAdvisoryTest(ctx *wf.TaskContext, name string, skipTests []string, run func() error) (testResult, error) {
for _, skip := range skipTests {
if skip == "all" || name == skip {
ctx.Printf("Skipping test")
return testResult{name, true}, nil
}
}
err := errors.New("untested") // prime the loop
for attempt := 1; attempt <= workflow.MaxRetries && err != nil; attempt++ {
ctx.Printf("======== Attempt %d of %d ========\n", attempt, workflow.MaxRetries)
err = run()
if err != nil {
ctx.Printf("Attempt failed: %v\n", err)
}
}
if err != nil {
ctx.Printf("Advisory test failed. Check the logs and approve this task if it's okay:\n")
return testResult{name, false}, b.ApproveAction(ctx)
}
return testResult{name, true}, nil
}
func (b *BuildReleaseTasks) checkTestResults(ctx *wf.TaskContext, results []testResult) error {
var fails []string
for _, r := range results {
if !r.Passed {
fails = append(fails, r.Name)
}
}
if len(fails) != 0 {
sort.Strings(fails)
ctx.Printf("Some advisory tests failed and their failures have been approved:\n%v", strings.Join(fails, "\n"))
return nil
}
return nil
}
// runBuildStep is a convenience function that manages resources a build step might need.
// If input with a scratch file is specified, its content will be opened and passed as a Reader to f.
// If outputSuffix is specified, a unique filename will be generated based off
// it (and the target name, if any), the file will be opened and passed as a
// Writer to f, and an artifact representing it will be returned as the result.
func (b *BuildReleaseTasks) runBuildStep(
ctx *wf.TaskContext,
target *releasetargets.Target,
input artifact,
outputSuffix string,
f func(io.Reader, io.Writer) error,
) (artifact, error) {
var err error
var in io.ReadCloser
if input.Scratch != "" {
in, err = b.ScratchFS.OpenRead(ctx, input.Scratch)
if err != nil {
return artifact{}, err
}
defer in.Close()
}
var out io.WriteCloser
var scratch string
hash := sha256.New()
size := &sizeWriter{}
var multiOut io.Writer
if outputSuffix != "" {
name := outputSuffix
if target != nil {
name = target.Name + "." + outputSuffix
}
scratch, out, err = b.ScratchFS.OpenWrite(ctx, name)
if err != nil {
return artifact{}, err
}
defer out.Close()
multiOut = io.MultiWriter(out, hash, size)
}
// Hide in's Close method from the task, which may assert it to Closer.
nopIn := io.NopCloser(in)
if err := f(nopIn, multiOut); err != nil {
return artifact{}, err
}
if in != nil {
if err := in.Close(); err != nil {
return artifact{}, err
}
}
if out != nil {
if err := out.Close(); err != nil {
return artifact{}, err
}
}
return artifact{
Target: target,
Scratch: scratch,
Suffix: outputSuffix,
SHA256: fmt.Sprintf("%x", string(hash.Sum([]byte(nil)))),
Size: size.size,
}, nil
}
// An artifact represents a file as it moves through the release process. Most
// files will appear on go.dev/dl eventually.
type artifact struct {
// The target platform of this artifact, or nil for source.
Target *releasetargets.Target
// The filename of this artifact, as used with the tasks' ScratchFS.
Scratch 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
// The final Filename of this artifact as it will be downloaded.
Filename string
SHA256 string
Size int
}
type sizeWriter struct {
size int
}
func (w *sizeWriter) Write(p []byte) (n int, err error) {
w.size += len(p)
return len(p), nil
}
func (tasks *BuildReleaseTasks) uploadArtifacts(ctx *wf.TaskContext, artifacts []artifact) error {
servingFS, err := gcsfs.FromURL(ctx, tasks.GCSClient, tasks.ServingURL)
if err != nil {
return err
}
want := map[string]bool{} // URLs we're waiting on becoming available.
for _, a := range artifacts {
if err := tasks.uploadFile(ctx, servingFS, a.Scratch, a.Filename); err != nil {
return err
}
want[tasks.DownloadURL+"/"+a.Filename] = true
if err := gcsfs.WriteFile(servingFS, a.Filename+".sha256", []byte(a.SHA256)); err != nil {
return err
}
want[tasks.DownloadURL+"/"+a.Filename+".sha256"] = true
if a.GPGSignature != "" {
if err := gcsfs.WriteFile(servingFS, a.Filename+".asc", []byte(a.GPGSignature)); err != nil {
return err
}
want[tasks.DownloadURL+"/"+a.Filename+".asc"] = true
}
}
_, err = task.AwaitCondition(ctx, 30*time.Second, checkFiles(ctx, want))
return err
}
func (tasks *BuildReleaseTasks) uploadModules(ctx *wf.TaskContext, version string, modules []moduleArtifact) error {
servingFS, err := gcsfs.FromURL(ctx, tasks.GCSClient, tasks.ServingURL)
if err != nil {
return err
}
want := map[string]bool{} // URLs we're waiting on becoming available.
for _, mod := range modules {
base := task.ToolchainModuleVersion(mod.Target, version)
if err := tasks.uploadFile(ctx, servingFS, mod.ZipScratch, base+".zip"); err != nil {
return err
}
if err := gcsfs.WriteFile(servingFS, base+".info", []byte(mod.Info)); err != nil {
return err
}
if err := gcsfs.WriteFile(servingFS, base+".mod", []byte(mod.Mod)); err != nil {
return err
}
for _, ext := range []string{".zip", ".info", ".mod"} {
want[tasks.DownloadURL+"/"+base+ext] = true
}
}
_, err = task.AwaitCondition(ctx, 30*time.Second, checkFiles(ctx, want))
return err
}
func (tasks *BuildReleaseTasks) awaitProxy(ctx *wf.TaskContext, version string, modules []moduleArtifact) error {
want := map[string]bool{}
for _, mod := range modules {
url := fmt.Sprintf("%v/%v.info", tasks.ProxyPrefix, task.ToolchainModuleVersion(mod.Target, version))
want[url] = true
}
_, err := task.AwaitCondition(ctx, 30*time.Second, checkFiles(ctx, want))
return err
}
func checkFiles(ctx context.Context, want map[string]bool) func() (int, bool, error) {
found := map[string]bool{}
return func() (int, bool, error) {
for url := range want {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := ctxhttp.Head(ctx, http.DefaultClient, url)
if err == context.DeadlineExceeded {
cancel()
continue
}
if err != nil {
return 0, false, err
}
resp.Body.Close()
cancel()
if resp.StatusCode == http.StatusOK {
found[url] = true
}
}
return 0, len(want) == len(found), nil
}
}
// uploadFile copies a file from tasks.ScratchFS to servingFS.
func (tasks *BuildReleaseTasks) uploadFile(ctx *wf.TaskContext, servingFS fs.FS, scratch, filename string) error {
in, err := tasks.ScratchFS.OpenRead(ctx, scratch)
if err != nil {
return err
}
defer in.Close()
out, err := gcsfs.Create(servingFS, filename)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
if err := out.Close(); err != nil {
return err
}
return nil
}
// publishArtifacts publishes artifacts for version (typically so they appear at https://go.dev/dl/).
// It returns the Go version and files that have been successfully published.
func (tasks *BuildReleaseTasks) publishArtifacts(ctx *wf.TaskContext, version string, artifacts []artifact) (task.Published, error) {
// Each release artifact corresponds to a single website file.
var files = make([]task.WebsiteFile, len(artifacts))
for i, a := range artifacts {
// Define website file metadata.
f := task.WebsiteFile{
Filename: a.Filename,
Version: version,
ChecksumSHA256: a.SHA256,
Size: int64(a.Size),
}
if a.Target != nil {
f.OS = a.Target.GOOS
f.Arch = a.Target.GOARCH
if a.Target.GOOS == "linux" && a.Target.GOARCH == "arm" && slices.Contains(a.Target.ExtraEnv, "GOARM=6") {
f.Arch = "armv6l"
}
if task.CompareGoVersions(version, "go1.23") == -1 { // TODO: Delete this after Go 1.24.0 is out and this becomes dead code.
// Due to an oversight, we've been inadvertently setting the "arch" field
// of published download metadata to "armv6l" for all arm ports, not just
// linux/arm port as intended. Keep doing it for the rest of Go 1.22/1.21
// minor releases only.
if a.Target.GOARCH == "arm" {
f.Arch = "armv6l"
}
}
}
switch a.Suffix {
case "src.tar.gz":
f.Kind = "source"
case "tar.gz", "zip":
f.Kind = "archive"
case "msi", "pkg":
f.Kind = "installer"
}
// Publish it.
if err := tasks.PublishFile(f); err != nil {
return task.Published{}, err
}
ctx.Printf("Published %q.", f.Filename)
files[i] = f
}
ctx.Printf("Published all %d files for %s.", len(files), version)
return task.Published{Version: version, Files: files}, nil
}
func (b *BuildReleaseTasks) runGoogleDockerBuild(ctx context.Context, version string) (task.CloudBuild, error) {
// Because we want to publish versions without the leading "go", it's easiest to strip it here.
v := strings.TrimPrefix(version, "go")
return b.CloudBuildClient.RunBuildTrigger(ctx, b.GoogleDockerBuildProject, b.GoogleDockerBuildTrigger, map[string]string{"_GO_VERSION": v})
}
func (b *BuildReleaseTasks) awaitCloudBuild(ctx *wf.TaskContext, build task.CloudBuild) (string, error) {
detail, err := task.AwaitCondition(ctx, 30*time.Second, func() (string, bool, error) {
return b.CloudBuildClient.Completed(ctx, build)
})
return detail, err
}
// jsonEncodeScratchFile JSON encodes v into a new scratch file and returns its name.
func jsonEncodeScratchFile(ctx *wf.TaskContext, fs *task.ScratchFS, v any) (name string, _ error) {
name, f, err := fs.OpenWrite(ctx, "f.json")
if err != nil {
return "", err
}
e := json.NewEncoder(f)
e.SetIndent("", "\t")
e.SetEscapeHTML(false)
if err := e.Encode(v); err != nil {
f.Close()
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
return name, nil
}