| // Copyright 2026 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 task | |
| import ( | |
| "errors" | |
| "fmt" | |
| "net/http" | |
| "regexp" | |
| "strconv" | |
| "strings" | |
| "time" | |
| "golang.org/x/build/gerrit" | |
| wf "golang.org/x/build/internal/workflow" | |
| "golang.org/x/build/relmeta" | |
| ) | |
| type PatchChanges struct { | |
| Patch *relmeta.SecurityPatch | |
| Changes []*gerrit.ChangeInfo | |
| } | |
| func securityRiders(p *relmeta.SecurityPatch) (string, error) { | |
| if p.CVE == "" { | |
| return "", fmt.Errorf("security patch %d has no CVE", p.ID) | |
| } | |
| if p.GitHubIssueID == 0 { | |
| return "", fmt.Errorf("security patch %d has no GitHub issue", p.ID) | |
| } | |
| return fmt.Sprintf("Fixes %s\nFor golang/go#%d", p.CVE, p.GitHubIssueID), nil | |
| } | |
| func insertRiders(message, riders string) string { | |
| message = strings.TrimRight(message, "\n") | |
| if i := strings.LastIndex(message, "\n\n"); i >= 0 && strings.Contains(message[i+2:], "Change-Id:") { | |
| return message[:i+2] + riders + "\n\n" + message[i+2:] + "\n" | |
| } | |
| return message + "\n\n" + riders + "\n" | |
| } | |
| func MoveAndRebaseAll(ctx *wf.TaskContext, client GerritClient, cp Checkpoint, patches []*PatchChanges) ([]*PatchChanges, error) { | |
| for _, p := range patches { | |
| riders, err := securityRiders(p.Patch) | |
| if err != nil { | |
| return nil, err | |
| } | |
| for i, ci := range p.Changes { | |
| // Idempotent. Changes can be in the MERGED (HTTP 409) state which means | |
| // that they cannot be moved or rebased. Refetch it and if it is MERGED, | |
| // skip it similarly to submitPrivateChanges. | |
| fresh, err := client.GetChange(ctx, ci.ID) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if fresh.Status == gerrit.ChangeStatusMerged { | |
| p.Changes[i] = fresh | |
| continue | |
| } | |
| movedCI, err := client.MoveChange(ctx, ci.ID, cp.Branch) | |
| if err != nil { | |
| var httpErr *gerrit.HTTPError | |
| if !errors.As(err, &httpErr) || httpErr.Res.StatusCode != http.StatusConflict || string(httpErr.Body) != "Change is already destined for the specified branch\n" { | |
| return nil, err | |
| } | |
| } else { | |
| ci = &movedCI | |
| } | |
| rebasedCI, err := client.RebaseChange(ctx, ci.ID, "") | |
| if err != nil { | |
| var httpErr *gerrit.HTTPError | |
| if !errors.As(err, &httpErr) || httpErr.Res.StatusCode != http.StatusConflict || string(httpErr.Body) != "Change is already up to date.\n" { | |
| return nil, err | |
| } | |
| } else { | |
| ci = &rebasedCI | |
| } | |
| cm, err := client.GetCommitMessage(ctx, ci.ID) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if !strings.Contains(cm, riders) { | |
| if err := client.SetCommitMessage(ctx, ci.ID, insertRiders(cm, riders)); err != nil { | |
| return nil, err | |
| } | |
| } | |
| p.Changes[i] = ci | |
| } | |
| } | |
| return patches, nil | |
| } | |
| var ( | |
| commitCVERE = regexp.MustCompile(`(?m)^Fixes CVE-\d{4}-\d+`) | |
| commitIssueRE = regexp.MustCompile(`(?m)^\w+ (?:golang/go)?#(\d+)`) | |
| RiderIssueRE = regexp.MustCompile(`(?m)^\w+ golang/go#(\d+)`) | |
| ) | |
| func PrivateChangeURL[T int | string](project string, clNum T) string { | |
| return fmt.Sprintf("https://go-internal-review.git.corp.google.com/c/%s/+/%v", project, clNum) | |
| } | |
| func CheckPrivateChanges(ctx *wf.TaskContext, client GerritClient, project string, patches []*relmeta.SecurityPatch) ([]*PatchChanges, error) { | |
| var ( | |
| checked []*PatchChanges | |
| lintErrs []error | |
| ) | |
| for _, p := range patches { | |
| if p.Track == relmeta.Public { | |
| continue | |
| } | |
| var cls []*gerrit.ChangeInfo | |
| for _, clURL := range p.Changelists { | |
| _, num, ok := strings.Cut(clURL, "/+/") | |
| if !ok { | |
| return nil, fmt.Errorf("security patch %d: malformed changelist URL %q", p.ID, clURL) | |
| } | |
| ci, err := client.GetChange(ctx, num, gerrit.QueryChangesOpt{Fields: []string{"SUBMITTABLE"}}) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if ci.Project != project { | |
| return nil, fmt.Errorf("change %s is for project %q, want %q", PrivateChangeURL(project, num), ci.Project, project) | |
| } | |
| if ci.Status == gerrit.ChangeStatusMerged { | |
| cls = append(cls, ci) | |
| continue | |
| } | |
| if !ci.Submittable { | |
| return nil, fmt.Errorf("change %s is not submittable", PrivateChangeURL(project, num)) | |
| } | |
| ra, err := client.GetRevisionActions(ctx, num, "current") | |
| if err != nil { | |
| return nil, err | |
| } | |
| if ra["submit"] == nil || !ra["submit"].Enabled { | |
| return nil, fmt.Errorf("change %s is not submittable", PrivateChangeURL(project, num)) | |
| } | |
| if ci.Branch == "public" { | |
| cm, err := client.GetCommitMessage(ctx, num) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if commitCVERE.MatchString(cm) { | |
| lintErrs = append(lintErrs, fmt.Errorf("change %s must not contain a CVE reference", PrivateChangeURL(project, num))) | |
| } | |
| if commitIssueRE.MatchString(cm) { | |
| lintErrs = append(lintErrs, fmt.Errorf("change %s must not contain a GitHub issue reference", PrivateChangeURL(project, num))) | |
| } | |
| } | |
| cls = append(cls, ci) | |
| } | |
| checked = append(checked, &PatchChanges{Patch: p, Changes: cls}) | |
| } | |
| if err := errors.Join(lintErrs...); err != nil { | |
| return nil, err | |
| } | |
| return checked, nil | |
| } | |
| func SubmitPrivateChanges(ctx *wf.TaskContext, client GerritClient, project string, patches []*PatchChanges) ([]*PatchChanges, error) { | |
| if _, err := AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) { | |
| var blocking []string | |
| for _, p := range patches { | |
| for i, change := range p.Changes { | |
| if change.Status == gerrit.ChangeStatusMerged { | |
| continue | |
| } | |
| ci, err := client.GetChange(ctx, change.ID, gerrit.QueryChangesOpt{Fields: []string{"SUBMITTABLE"}}) | |
| if err != nil { | |
| return "", false, err | |
| } | |
| if ci.Status == gerrit.ChangeStatusMerged { | |
| p.Changes[i] = ci | |
| continue | |
| } | |
| if !ci.Submittable { | |
| blocking = append(blocking, PrivateChangeURL(project, ci.ChangeNumber)) | |
| continue | |
| } | |
| submitted, err := client.SubmitChange(ctx, ci.ID) | |
| if err != nil { | |
| return "", false, err | |
| } | |
| p.Changes[i] = &submitted | |
| } | |
| } | |
| if len(blocking) == 0 { | |
| return "", true, nil | |
| } | |
| ctx.Printf("awaiting non-submittable CL(s): %s", strings.Join(blocking, ", ")) | |
| return "", false, nil | |
| }); err != nil { | |
| return nil, err | |
| } | |
| return patches, nil | |
| } | |
| func AwaitSubmitted(ctx *wf.TaskContext, client GerritClient, changeIDs []string) error { | |
| if len(changeIDs) == 0 { | |
| ctx.Printf("No CLs were necessary.") | |
| return nil | |
| } | |
| ctx.Printf("Awaiting review/submit of %d changes.", len(changeIDs)) | |
| for _, c := range changeIDs { | |
| ctx.Printf("• %s", ChangeLink(c)) | |
| } | |
| _, err := AwaitCondition(ctx, 10*time.Second, func() (struct{}, bool, error) { | |
| for _, c := range changeIDs { | |
| _, submitted, err := client.Submitted(ctx, c, "") | |
| if err != nil { | |
| return struct{}{}, false, err | |
| } | |
| if !submitted { | |
| return struct{}{}, false, nil | |
| } | |
| } | |
| return struct{}{}, true, nil | |
| }) | |
| return err | |
| } | |
| type Checkpoint struct { | |
| Branch string | |
| StartingHead string | |
| } | |
| func CreateCheckpoint(ctx *wf.TaskContext, client GerritClient, project, prefix string) (Checkpoint, error) { | |
| publicHead, err := client.ReadBranchHead(ctx, project, "public") | |
| if err != nil { | |
| return Checkpoint{}, err | |
| } | |
| // Append the formatted timestamp to make any restarts idempotent. | |
| branch := prefix + "-" + time.Now().UTC().Format("20060102-150405") | |
| if _, err := client.CreateBranch(ctx, project, branch, gerrit.BranchInput{Revision: publicHead}); err != nil { | |
| return Checkpoint{}, err | |
| } | |
| return Checkpoint{Branch: branch, StartingHead: publicHead}, nil | |
| } | |
| func ConvertPatchChangelists(ctx *wf.TaskContext, private, public GerritClient, project string, rm *relmeta.ReleaseMilestone, patches []*PatchChanges, reviewers []string) (*relmeta.ReleaseMilestone, error) { | |
| if rm == nil || len(patches) == 0 { | |
| return rm, nil | |
| } | |
| var sps []*relmeta.SecurityPatch | |
| for _, p := range patches { | |
| sps = append(sps, p.Patch) | |
| } | |
| external, err := ResolveExternalChangelists(ctx, private, public, project, sps) | |
| if err != nil { | |
| return nil, err | |
| } | |
| converted, err := ConvertInternalChangelists(ctx, private, strconv.FormatInt(rm.ID, 10), external, reviewers) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return &converted, nil | |
| } |