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 {