cmd/relui: add govulncheck-action repo tagging workflow Add a new workflow for tagging and releasing the govulncheck-action repository. The workflow automates several manual steps: - Detection of the latest tagged version for reference. - Strict semantic version validation (increment checks and major version jump protection). - Automated tagging of both the specific version (e.g., v1.2.3) and move the corresponding major version tag (e.g., v1). - Wait for the new tag to be mirrored to GitHub (polling with a 15-minute timeout). - Automatic creation of a GitHub release with auto-generated notes. The workflow is restricted to the security team for approval. For golang/go#77863 Change-Id: I83f26c74c951919505861a5400a9eb40573644af Reviewed-on: https://go-review.googlesource.com/c/build/+/750120 Reviewed-by: Dmitri Shuralyov <dmitshur@google.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/relui/main.go b/cmd/relui/main.go index d74443f..e5c6b29 100644 --- a/cmd/relui/main.go +++ b/cmd/relui/main.go
@@ -395,6 +395,13 @@ dh.RegisterDefinition("Prepare a pre-release gopls candidate", goplsTasks.NewPrereleaseDefinition()) dh.RegisterDefinition("Release gopls", goplsTasks.NewReleaseDefinition()) + govulncheckActionTasks := task.ReleaseGovulncheckActionTasks{ + Gerrit: gerritClient, + GitHub: githubClient, + ApproveAction: relui.ApproveActionDep(dbPool), + } + dh.RegisterDefinition("Tag govulncheck-action", govulncheckActionTasks.NewDefinition()) + privateSyncTask := &task.PrivateMasterSyncTask{ Git: gitClient, PrivateGerritURL: "https://go-internal.googlesource.com/golang/go-private",
diff --git a/internal/task/fakes.go b/internal/task/fakes.go index fb05d11..8e147d4 100644 --- a/internal/task/fakes.go +++ b/internal/task/fakes.go
@@ -1382,6 +1382,9 @@ DisallowComments bool // if set, return an error from PostComment lastIssueNumber int // last issue number created by nextIssueNumber, or 0 lastMilestoneID int // last milestone ID created by nextMilestoneID, or 0 + + // Tags is a set of tags that exist on GitHub for testing. + Tags map[string]bool } func (f *FakeGitHub) nextMilestoneID() int { @@ -1455,6 +1458,10 @@ return nil, nil } +func (f *FakeGitHub) TagExists(ctx context.Context, owner, repo, tag string) (bool, error) { + return f.Tags[tag], nil +} + func (*FakeGitHub) EditIssue(_ context.Context, owner, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) { return nil, nil, nil }
diff --git a/internal/task/govulncheckaction.go b/internal/task/govulncheckaction.go new file mode 100644 index 0000000..6cd62eb --- /dev/null +++ b/internal/task/govulncheckaction.go
@@ -0,0 +1,193 @@ +// 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 ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/go-github/v74/github" + "golang.org/x/build/internal/relui/groups" + wf "golang.org/x/build/internal/workflow" + "golang.org/x/mod/semver" +) + +// ReleaseGovulncheckActionTasks provides workflow definitions and tasks for +// tagging the govulncheck-action repository. +type ReleaseGovulncheckActionTasks struct { + Gerrit DestructiveGerritClient // destructive needed to overwrite moving major tag (e.g., "v1") + GitHub GitHubClientInterface + ApproveAction func(*wf.TaskContext) error +} + +// NewDefinition returns a workflow definition for releasing a new version of +// the govulncheck-action repository. +func (x *ReleaseGovulncheckActionTasks) NewDefinition() *wf.Definition { + wd := wf.New(wf.ACL{Groups: []string{groups.SecurityTeam}}) + version := wf.Param(wd, wf.ParamDef[string]{ + Name: "Version", + Example: "v1.0.0", + Check: CheckSemver, + }) + + latest := wf.Task0(wd, "find latest version", x.findLatestVersion) + validated := wf.Action2(wd, "validate version", x.validateVersion, version, latest) + commit := wf.Task2(wd, "read branch head", x.Gerrit.ReadBranchHead, wf.Const("govulncheck-action"), wf.Const("master")) + approved := wf.Action3(wd, "wait for approval", x.approveTagging, version, commit, latest, wf.After(validated)) + tag := wf.Action2(wd, "tag repository", x.tagRepository, version, commit, wf.After(approved)) + majorTag := wf.Action2(wd, "update major tag", x.updateMajorTag, version, commit, wf.After(tag)) + awaitTag := wf.Action1(wd, "wait for GitHub tag sync", x.awaitGitHubTag, version, wf.After(majorTag)) + githubRelease := wf.Action1(wd, "create GitHub release", x.createGitHubRelease, version, wf.After(awaitTag)) + + done := wf.Task1(wd, "await GitHub release", func(_ context.Context, v string) (string, error) { + return v, nil + }, version, wf.After(githubRelease)) + wf.Output(wd, "tagged version", done) + return wd +} + +func (x *ReleaseGovulncheckActionTasks) validateVersion(ctx *wf.TaskContext, new, latest string) error { + if latest == "" { + return nil + } + if semver.Compare(new, latest) <= 0 { + return fmt.Errorf("proposed version %q is not greater than latest version %q", new, latest) + } + majorNew, minorNew, patchNew, err := parseSemver(new) + if err != nil { + return err + } + majorLatest, minorLatest, patchLatest, err := parseSemver(latest) + if err != nil { + return err + } + + if majorNew == majorLatest+1 { + if minorNew != 0 || patchNew != 0 { + return fmt.Errorf("proposed major jump %q must reset minor and patch to 0", new) + } + return nil + } + + if majorNew == majorLatest { + if minorNew == minorLatest+1 { + if patchNew != 0 { + return fmt.Errorf("proposed minor jump %q must reset patch to 0", new) + } + return nil + } + if minorNew == minorLatest { + if patchNew != patchLatest+1 { + return fmt.Errorf("proposed patch %q is not exactly %d.%d.%d", new, majorLatest, minorLatest, patchLatest+1) + } + return nil + } + return fmt.Errorf("proposed minor version %d is not valid (latest minor is %d)", minorNew, minorLatest) + } + + return fmt.Errorf("proposed major version %q is not valid (latest major is %q)", semver.Major(new), semver.Major(latest)) +} + +func parseSemver(v string) (major, minor, patch int, err error) { + // Trim any pre-release or build metadata for parsing base components + base := v + if i := strings.IndexAny(v, "-+"); i != -1 { + base = v[:i] + } + if _, err = fmt.Sscanf(base, "v%d.%d.%d", &major, &minor, &patch); err != nil { + return 0, 0, 0, fmt.Errorf("invalid semver %q: %v", v, err) + } + return major, minor, patch, nil +} + +func parseMajor(v string) (int, error) { + var n int + if _, err := fmt.Sscanf(v, "v%d", &n); err != nil { + return 0, fmt.Errorf("invalid major version %q: %v", v, err) + } + return n, nil +} + +func (x *ReleaseGovulncheckActionTasks) findLatestVersion(ctx *wf.TaskContext) (string, error) { + tags, err := x.Gerrit.ListTags(ctx, "govulncheck-action") + if err != nil { + return "", err + } + latest := "" + for _, t := range tags { + if !semver.IsValid(t) { + continue + } + if latest == "" || semver.Compare(t, latest) > 0 { + latest = t + } + } + if latest == "" { + ctx.Printf("No existing tags found.") + } else { + ctx.Printf("Latest version found: %s", latest) + } + return latest, nil +} + +func (x *ReleaseGovulncheckActionTasks) approveTagging(ctx *wf.TaskContext, version string, commit string, latest string) error { + if latest != "" { + ctx.Printf("Latest version tag: %s", latest) + } else { + ctx.Printf("No previous version found.") + } + ctx.Printf("Tagging govulncheck-action as %s at commit %s.\n", version, commit) + ctx.Printf("This action requires approval.") + return x.ApproveAction(ctx) +} + +func (x *ReleaseGovulncheckActionTasks) tagRepository(ctx *wf.TaskContext, version string, commit string) error { + ctx.Printf("Tagging govulncheck-action as %s at commit %s", version, commit) + ctx.DisableRetries() + return x.Gerrit.Tag(ctx, "govulncheck-action", version, commit) +} + +func (x *ReleaseGovulncheckActionTasks) updateMajorTag(ctx *wf.TaskContext, version string, commit string) error { + major := semver.Major(version) + if major == "" { + return fmt.Errorf("invalid version %q: no major version", version) + } + ctx.Printf("Updating major tag %s to point to %s", major, commit) + ctx.DisableRetries() // Manual retries beyond this point. + return x.Gerrit.ForceTag(ctx, "govulncheck-action", major, commit) +} + +func (x *ReleaseGovulncheckActionTasks) awaitGitHubTag(ctx *wf.TaskContext, tag string) error { + ctx.Printf("Waiting for tag %s to be mirrored to GitHub", tag) + tctx, cancel := context.WithTimeout(ctx, 15*time.Minute) + defer cancel() + _, err := AwaitCondition(ctx, 15*time.Second, func() (string, bool, error) { + exists, err := x.GitHub.TagExists(tctx, "golang", "govulncheck-action", tag) + return "", exists, err + }) + return err +} + +func (x *ReleaseGovulncheckActionTasks) createGitHubRelease(ctx *wf.TaskContext, version string) error { + ctx.DisableRetries() + ctx.Printf("Creating GitHub release for %s", version) + _, err := x.GitHub.CreateRelease(ctx, "golang", "govulncheck-action", &github.RepositoryRelease{ + TagName: github.Ptr(version), + Name: github.Ptr(version), + GenerateReleaseNotes: github.Ptr(true), + Draft: github.Ptr(false), + }) + return err +} + +// CheckSemver reports whether v is a valid semantic version. +func CheckSemver(v string) error { + if !semver.IsValid(v) { + return fmt.Errorf("invalid semver: %q", v) + } + return nil +}
diff --git a/internal/task/govulncheckaction_test.go b/internal/task/govulncheckaction_test.go new file mode 100644 index 0000000..ed4a252 --- /dev/null +++ b/internal/task/govulncheckaction_test.go
@@ -0,0 +1,224 @@ +// 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 ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + "golang.org/x/build/internal/workflow" +) + +func TestReleaseGovulncheckActionTasks_NewDefinition(t *testing.T) { + fakeRepo := NewFakeRepo(t, "govulncheck-action") + fakeRepo.Tag("v1.1.0", "master") + commit := fakeRepo.Commit(map[string]string{"README.md": "New content"}) + + fakeGerrit := NewFakeGerrit(t, fakeRepo) + fakeGitHub := &FakeGitHub{ + Tags: map[string]bool{"v1.2.0": true}, + } + + tasks := &ReleaseGovulncheckActionTasks{ + Gerrit: fakeGerrit, + GitHub: fakeGitHub, + ApproveAction: func(ctx *workflow.TaskContext) error { + return nil + }, + } + + wd := tasks.NewDefinition() + + ctx := t.Context() + + // Start the workflow with version v1.2.0 + w, err := workflow.Start(wd, map[string]any{ + "Version": "v1.2.0", + }) + if err != nil { + t.Fatalf("workflow.Start failed: %v", err) + } + + if _, err := w.Run(ctx, &govulncheckActionVerboseListener{t: t}); err != nil { + t.Fatalf("workflow.Run failed: %v", err) + } + + // Verify tags + tags, err := fakeGerrit.ListTags(ctx, "govulncheck-action") + if err != nil { + t.Fatalf("ListTags failed: %v", err) + } + + foundv120 := false + foundv1 := false + for _, tag := range tags { + if tag == "v1.2.0" { + foundv120 = true + } + if tag == "v1" { + foundv1 = true + } + } + + if !foundv120 { + t.Errorf("tag v1.2.0 not found in %v", tags) + } + if !foundv1 { + t.Errorf("major tag v1 not found in %v", tags) + } + + // Verify GitHub release + if len(fakeGitHub.Releases) != 1 { + t.Errorf("got %d GitHub releases, want 1", len(fakeGitHub.Releases)) + } else { + rel := fakeGitHub.Releases[0] + if *rel.TagName != "v1.2.0" { + t.Errorf("GitHub release TagName = %q, want %q", *rel.TagName, "v1.2.0") + } + if rel.GenerateReleaseNotes == nil || !*rel.GenerateReleaseNotes { + t.Error("GitHub release GenerateReleaseNotes NOT set to true") + } + } + + // Verify major tag points to the right commit + info, err := fakeGerrit.GetTag(ctx, "govulncheck-action", "v1") + if err != nil { + t.Fatalf("GetTag v1 failed: %v", err) + } + if info.Revision != commit { + t.Errorf("v1 tag points to %s, want %s", info.Revision, commit) + } +} + +func TestReleaseGovulncheckActionTasks_Validation(t *testing.T) { + testCases := []struct { + name string + latest string + new string + wantErr bool + }{ + { + name: "valid minor increment", + latest: "v1.1.0", + new: "v1.2.0", + wantErr: false, + }, + { + name: "valid major increment", + latest: "v1.5.0", + new: "v2.0.0", + wantErr: false, + }, + { + name: "invalid regression", + latest: "v1.2.0", + new: "v1.1.0", + wantErr: true, + }, + { + name: "invalid same version", + latest: "v1.2.0", + new: "v1.2.0", + wantErr: true, + }, + { + name: "invalid skip major", + latest: "v1.5.0", + new: "v3.0.0", + wantErr: true, + }, + { + name: "invalid skip minor", + latest: "v1.3.0", + new: "v1.5.0", + wantErr: true, + }, + { + name: "invalid skip patch", + latest: "v1.3.0", + new: "v1.3.2", + wantErr: true, + }, + { + name: "valid first version", + latest: "", + new: "v1.0.0", + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fakeRepo := NewFakeRepo(t, "govulncheck-action") + if tc.latest != "" { + fakeRepo.Tag(tc.latest, "master") + } + fakeGerrit := NewFakeGerrit(t, fakeRepo) + fakeGitHub := &FakeGitHub{ + Tags: map[string]bool{tc.new: true}, + } + tasks := &ReleaseGovulncheckActionTasks{ + Gerrit: fakeGerrit, + GitHub: fakeGitHub, + ApproveAction: func(ctx *workflow.TaskContext) error { + return nil + }, + } + + wd := tasks.NewDefinition() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + w, err := workflow.Start(wd, map[string]any{ + "Version": tc.new, + }) + if err != nil { + t.Fatalf("workflow.Start failed: %v", err) + } + + _, err = w.Run(ctx, &govulncheckActionVerboseListener{t: t, cancel: cancel}) + if (err != nil) != tc.wantErr { + t.Errorf("workflow.Run error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +type govulncheckActionVerboseListener struct { + t *testing.T + cancel context.CancelFunc +} + +func (l *govulncheckActionVerboseListener) WorkflowStalled(workflowID uuid.UUID) error { + l.t.Logf("workflow %q: stalled", workflowID.String()) + if l.cancel != nil { + l.cancel() + } + return nil +} + +func (l *govulncheckActionVerboseListener) TaskStateChanged(_ uuid.UUID, _ string, st *workflow.TaskState) error { + if st.Finished && st.Error != "" { + l.t.Logf("task %-10v: error: %v", st.Name, st.Error) + } else if st.Finished { + l.t.Logf("task %-10v: done: %v", st.Name, st.Result) + } + return nil +} + +func (l *govulncheckActionVerboseListener) Logger(_ uuid.UUID, task string) workflow.Logger { + return &govulncheckActionTestLogger{t: l.t, task: task} +} + +type govulncheckActionTestLogger struct { + t *testing.T + task string +} + +func (l *govulncheckActionTestLogger) Printf(format string, v ...any) { + l.t.Logf("task %-10v: LOG: %s", l.task, fmt.Sprintf(format, v...)) +}
diff --git a/internal/task/milestones.go b/internal/task/milestones.go index 8f3f020..61a0b6e 100644 --- a/internal/task/milestones.go +++ b/internal/task/milestones.go
@@ -389,6 +389,9 @@ // - fileName: The name of the asset as it will appear in the release. // - file: The content of the file to upload. UploadReleaseAsset(ctx context.Context, owner, repo string, releaseID int64, fileName string, file fs.File) (*github.ReleaseAsset, error) + + // TagExists reports whether the specified tag exists in the repository. + TagExists(ctx context.Context, owner, repo, tag string) (bool, error) } type GitHubClient struct { @@ -455,6 +458,17 @@ return release, err } +func (c *GitHubClient) TagExists(ctx context.Context, owner, repo, tag string) (bool, error) { + _, resp, err := c.V3.Git.GetRef(ctx, owner, repo, "tags/"+tag) + if resp != nil && resp.StatusCode == 404 { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + func findMilestone(ctx context.Context, client *githubv4.Client, owner, repo, name string) (int, bool, error) { var query struct { Repository struct {