| // 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, fmt.Sprintf(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 |
| } |