From 8bc59de6f2a086f1e06465050633381fb3399d75 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Tue, 18 Aug 2026 15:54:15 -0700 Subject: [PATCH] Add moving major branch publishing --- README.md | 5 +- pkg/publish.go | 35 ++++++++++++-- pkg/publish_git.go | 58 ++++++++++++++++++++++++ pkg/publish_major_branch_test.go | 78 ++++++++++++++++++++++++++++++++ publish_cli.go | 4 ++ publish_cli_test.go | 31 ++++++++++--- 6 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 pkg/publish_major_branch_test.go diff --git a/README.md b/README.md index 86fcec1..6ebac37 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ goversion publish 4. Atomically publishes only incomplete branch or version-tag refs to the configured remote. 5. Creates or reuses a GitHub Release with generated notes through the authenticated `gh` CLI. 6. Seeds and verifies the complete module through `go mod download` using the configured Go proxy. +7. With `-major-branch`, creates or advances the moving `vN` branch for GitHub Action consumers only after every earlier step succeeds. If `gh` is missing or unauthenticated, publishing continues without a GitHub Release and prints an actionable warning. A failure from an available and authenticated `gh` command remains fatal so a real release error is not silently ignored. @@ -262,11 +263,13 @@ goversion publish -proxy https://proxy.golang.org goversion publish -timeout 5m goversion publish -no-release goversion publish -no-proxy +goversion publish -major-branch # publish vN (for example v2) after the release and proxy ``` Publishing is resumable after a failure. -Each branch, tag, GitHub Release, and proxy step reports a `planned`, `completed`, `reused`, or `skipped` status. +Each branch, tag, GitHub Release, proxy, and moving major-branch step reports a `planned`, `completed`, `reused`, or `skipped` status. On retry, `goversion` skips Git refs that already point to the expected commit, reuses an existing GitHub Release, and continues with the first incomplete stage. +The moving major branch is opt-in because it is intended for GitHub Actions referenced as `owner/action@vN`. It is derived from the validated semantic version and updated with a force-with-lease pinned to the remote value observed during preflight, preventing an unseen concurrent update from being overwritten. If proxy seeding was the failed stage, read-only Git and GitHub checks are repeated before retrying the proxy request. It reports a `pkg.go.dev` URL, but documentation indexing may complete asynchronously after the proxy accepts the module. diff --git a/pkg/publish.go b/pkg/publish.go index 7c39458..17e128b 100644 --- a/pkg/publish.go +++ b/pkg/publish.go @@ -60,6 +60,8 @@ type PublishOptions struct { NoProxy bool // NoRelease skips GitHub Release creation. NoRelease bool + // MajorBranch publishes a moving vN branch for GitHub Action consumers. + MajorBranch bool // Timeout limits each external git, gh, and go command. // It defaults to two minutes. A negative value disables the timeout. Timeout time.Duration @@ -82,6 +84,8 @@ type PublishMeta struct { Branch string // Remote is the Git remote used for publication. Remote string + // MajorBranch is the moving major-version branch derived from Version, such as v2. + MajorBranch string // BranchStatus describes whether the remote branch was planned, completed, or reused. BranchStatus PublishStepStatus // TagStatus describes whether the remote tag was planned, completed, or reused. @@ -90,6 +94,8 @@ type PublishMeta struct { ReleaseStatus PublishStepStatus // ProxyStatus describes whether proxy seeding was planned, completed, or skipped. ProxyStatus PublishStepStatus + // MajorBranchStatus describes whether the moving major branch was planned, completed, reused, or skipped. + MajorBranchStatus PublishStepStatus // ReleaseURL is the URL of the created or reused GitHub Release. ReleaseURL string // Warnings contains non-fatal conditions encountered while publishing. @@ -214,10 +220,11 @@ func PublishContext(ctx context.Context, options PublishOptions) (PublishMeta, e // publish coordinates publication using an injectable command runner. func publish(options PublishOptions, runner publishCommandRunner) (PublishMeta, error) { meta := PublishMeta{ - BranchStatus: PublishStepPending, - TagStatus: PublishStepPending, - ReleaseStatus: PublishStepPending, - ProxyStatus: PublishStepPending, + BranchStatus: PublishStepPending, + TagStatus: PublishStepPending, + ReleaseStatus: PublishStepPending, + ProxyStatus: PublishStepPending, + MajorBranchStatus: PublishStepPending, } publishProgress(options.Progress, "Validating module and version") @@ -273,12 +280,16 @@ func publish(options PublishOptions, runner publishCommandRunner) (PublishMeta, return meta, err } meta.Version = version + meta.MajorBranch = semver.Major(version) publishProgress(options.Progress, "Inspecting local and remote Git state") gitState, err := inspectPublishGit(workDir, modPath, &meta, runner) if err != nil { return meta, err } + if err := inspectPublishMajorBranch(workDir, &meta, &gitState, runner, options.MajorBranch); err != nil { + return meta, err + } if options.DryRun { publishProgress(options.Progress, "Validating Git ref publication (dry run)") @@ -294,6 +305,14 @@ func publish(options PublishOptions, runner publishCommandRunner) (PublishMeta, return meta, err } planPublishProxy(&meta, options.NoProxy) + if options.MajorBranch { + publishProgress(options.Progress, "Validating moving major branch publication (dry run)") + } else { + publishProgress(options.Progress, "Skipping moving major branch") + } + if err := publishMajorBranch(workDir, &meta, gitState, runner, true, options.MajorBranch); err != nil { + return meta, err + } return meta, nil } @@ -317,6 +336,14 @@ func publish(options PublishOptions, runner publishCommandRunner) (PublishMeta, if err := seedPublishProxy(filepath.Dir(modPath), proxy, &meta, runner, options.Progress, options.NoProxy); err != nil { return meta, err } + if options.MajorBranch { + publishProgress(options.Progress, "Publishing moving major branch") + } else { + publishProgress(options.Progress, "Skipping moving major branch") + } + if err := publishMajorBranch(workDir, &meta, gitState, runner, false, options.MajorBranch); err != nil { + return meta, err + } return meta, nil } diff --git a/pkg/publish_git.go b/pkg/publish_git.go index 08bfa11..b0b75a1 100644 --- a/pkg/publish_git.go +++ b/pkg/publish_git.go @@ -11,6 +11,7 @@ type publishGitState struct { remoteURL string remoteBranchCommit string remoteTagCommit string + remoteMajorCommit string } // inspectPublishGit validates local Git state and records the corresponding remote refs. @@ -82,6 +83,63 @@ func inspectPublishGit(workDir, modPath string, meta *PublishMeta, runner publis return state, nil } +// inspectPublishMajorBranch records the opt-in moving major branch checkpoint. +func inspectPublishMajorBranch(workDir string, meta *PublishMeta, state *publishGitState, runner publishCommandRunner, enabled bool) error { + if !enabled { + meta.MajorBranchStatus = PublishStepSkipped + return nil + } + remoteCommit, err := inspectRemoteBranch(workDir, meta.Remote, meta.MajorBranch, runner) + if err != nil { + return err + } + state.remoteMajorCommit = remoteCommit + meta.MajorBranchStatus = statusForRemoteCommit(remoteCommit, meta.HeadCommit) + return nil +} + +// publishMajorBranch validates or publishes the opt-in moving major branch using the observed remote value as a lease. +func publishMajorBranch(workDir string, meta *PublishMeta, state publishGitState, runner publishCommandRunner, dryRun, enabled bool) error { + if !enabled { + meta.MajorBranchStatus = PublishStepSkipped + return nil + } + if state.remoteMajorCommit == meta.HeadCommit { + meta.MajorBranchStatus = PublishStepReused + return nil + } + + ref := "refs/heads/" + meta.MajorBranch + lease := "--force-with-lease=" + ref + ":" + state.remoteMajorCommit + if dryRun { + args := []string{"push", "--dry-run", lease, meta.Remote, meta.HeadCommit + ":" + ref} + output, err := runner.Run(workDir, nil, "git", args...) + if err != nil { + return publishCommandError("validate moving major branch "+meta.MajorBranch, output, err, "git", args...) + } + meta.MajorBranchStatus = PublishStepPlanned + return nil + } + + updateArgs := []string{"update-ref", ref, meta.HeadCommit} + if output, err := runner.Run(workDir, nil, "git", updateArgs...); err != nil { + return publishCommandError("update local moving major branch "+meta.MajorBranch, output, err, "git", updateArgs...) + } + pushArgs := []string{"push", lease, meta.Remote, ref + ":" + ref} + if output, err := runner.Run(workDir, nil, "git", pushArgs...); err != nil { + return publishCommandError("publish moving major branch "+meta.MajorBranch, output, err, "git", pushArgs...) + } + remoteCommit, err := inspectRemoteBranch(workDir, meta.Remote, meta.MajorBranch, runner) + if err != nil { + return err + } + if remoteCommit != meta.HeadCommit { + return fmt.Errorf("remote major branch %s on %s resolves to %q after push, expected HEAD %s", meta.MajorBranch, meta.Remote, remoteCommit, meta.HeadCommit) + } + meta.MajorBranchStatus = PublishStepCompleted + return nil +} + // statusForRemoteCommit reports whether a matching remote commit can be reused. func statusForRemoteCommit(remoteCommit, headCommit string) PublishStepStatus { if remoteCommit == headCommit { diff --git a/pkg/publish_major_branch_test.go b/pkg/publish_major_branch_test.go new file mode 100644 index 0000000..509e94e --- /dev/null +++ b/pkg/publish_major_branch_test.go @@ -0,0 +1,78 @@ +package goversion + +import ( + "errors" + "strings" + "testing" +) + +func TestPublishMajorBranchDryRunUsesAbsentLeaseWithoutLocalMutation(t *testing.T) { + head := "1111111111111111111111111111111111111111" + ref := "refs/heads/v2" + runner := &publishTestRunner{t: t, commands: []publishTestCommand{ + {name: "git", args: []string{"push", "--dry-run", "--force-with-lease=" + ref + ":", "upstream", head + ":" + ref}}, + }} + meta := PublishMeta{Remote: "upstream", MajorBranch: "v2", HeadCommit: head, MajorBranchStatus: PublishStepPlanned} + + if err := publishMajorBranch(t.TempDir(), &meta, publishGitState{}, runner, true, true); err != nil { + t.Fatalf("publishMajorBranch dry run returned error: %v", err) + } + runner.done() + if meta.MajorBranchStatus != PublishStepPlanned { + t.Fatalf("got status %q, want planned", meta.MajorBranchStatus) + } +} + +func TestPublishMajorBranchAdvancesWithObservedLeaseAndVerifies(t *testing.T) { + head := "2222222222222222222222222222222222222222" + observed := "1111111111111111111111111111111111111111" + ref := "refs/heads/v3" + runner := &publishTestRunner{t: t, commands: []publishTestCommand{ + {name: "git", args: []string{"update-ref", ref, head}}, + {name: "git", args: []string{"push", "--force-with-lease=" + ref + ":" + observed, "upstream", ref + ":" + ref}}, + {name: "git", args: []string{"ls-remote", "--heads", "upstream", ref}, out: head + "\t" + ref + "\n"}, + }} + meta := PublishMeta{Remote: "upstream", MajorBranch: "v3", HeadCommit: head, MajorBranchStatus: PublishStepPlanned} + + if err := publishMajorBranch(t.TempDir(), &meta, publishGitState{remoteMajorCommit: observed}, runner, false, true); err != nil { + t.Fatalf("publishMajorBranch returned error: %v", err) + } + runner.done() + if meta.MajorBranchStatus != PublishStepCompleted { + t.Fatalf("got status %q, want completed", meta.MajorBranchStatus) + } +} + +func TestPublishMajorBranchReusesCurrentRemote(t *testing.T) { + head := "3333333333333333333333333333333333333333" + runner := &publishTestRunner{t: t} + meta := PublishMeta{Remote: "origin", MajorBranch: "v1", HeadCommit: head, MajorBranchStatus: PublishStepReused} + + if err := publishMajorBranch(t.TempDir(), &meta, publishGitState{remoteMajorCommit: head}, runner, false, true); err != nil { + t.Fatalf("publishMajorBranch returned error: %v", err) + } + runner.done() + if meta.MajorBranchStatus != PublishStepReused { + t.Fatalf("got status %q, want reused", meta.MajorBranchStatus) + } +} + +func TestPublishMajorBranchLeaseFailureIsResumable(t *testing.T) { + head := "4444444444444444444444444444444444444444" + observed := "3333333333333333333333333333333333333333" + ref := "refs/heads/v4" + runner := &publishTestRunner{t: t, commands: []publishTestCommand{ + {name: "git", args: []string{"update-ref", ref, head}}, + {name: "git", args: []string{"push", "--force-with-lease=" + ref + ":" + observed, "origin", ref + ":" + ref}, out: "stale info\n", err: errors.New("exit 1")}, + }} + meta := PublishMeta{Remote: "origin", MajorBranch: "v4", HeadCommit: head, MajorBranchStatus: PublishStepPlanned} + + err := publishMajorBranch(t.TempDir(), &meta, publishGitState{remoteMajorCommit: observed}, runner, false, true) + if err == nil || !strings.Contains(err.Error(), "stale info") { + t.Fatalf("expected lease failure, got %v", err) + } + runner.done() + if meta.MajorBranchStatus != PublishStepPlanned { + t.Fatalf("failed step changed status to %q", meta.MajorBranchStatus) + } +} diff --git a/publish_cli.go b/publish_cli.go index 177a19a..889d3dc 100644 --- a/publish_cli.go +++ b/publish_cli.go @@ -21,6 +21,7 @@ func runPublishCommand(arguments []string, output, errorOutput io.Writer) int { dryRun := flags.Bool("dry", false, "Validate and preview publishing without changing remote state") noProxy := flags.Bool("no-proxy", false, "Push and create the GitHub release without seeding a Go module proxy") noRelease := flags.Bool("no-release", false, "Push and seed the Go proxy without creating a GitHub Release") + majorBranch := flags.Bool("major-branch", false, "Create or advance the moving vN branch after all other publish steps succeed") help := flags.Bool("help", false, "Show publish help and exit") flags.Usage = func() { printPublishUsage(flags.Output(), flags) } @@ -48,6 +49,7 @@ func runPublishCommand(arguments []string, output, errorOutput io.Writer) int { DryRun: *dryRun, NoProxy: *noProxy, NoRelease: *noRelease, + MajorBranch: *majorBranch, Timeout: *timeout, Progress: errorOutput, }) @@ -73,6 +75,7 @@ func runPublishCommand(arguments []string, output, errorOutput io.Writer) int { printPublishStep(output, "Tag", meta.TagStatus, "publish tag", "published", "already current") printReleaseStep(output, meta) printProxyStep(output, meta, *proxy) + printPublishStep(output, "Major", meta.MajorBranchStatus, "publish "+meta.MajorBranch, "published "+meta.MajorBranch, meta.MajorBranch+" already current") return 0 } @@ -132,6 +135,7 @@ func printPublishUsage(output io.Writer, flags *flag.FlagSet) { Publishes an existing goversion commit and tag as a Go module. The command atomically publishes incomplete Git refs, creates or reuses a GitHub Release through gh when available, and seeds the Go module proxy. +With -major-branch, it then creates or advances the moving vN branch used by GitHub Actions. Run goversion , validate the local release, then run goversion publish. diff --git a/publish_cli_test.go b/publish_cli_test.go index 5b645c6..eb557e1 100644 --- a/publish_cli_test.go +++ b/publish_cli_test.go @@ -13,7 +13,7 @@ func TestCLIPublishHelp(t *testing.T) { if err != nil { t.Fatalf("publish help failed: %v\n%s", err, out) } - for _, want := range []string{"goversion publish [options]", "-timeout duration"} { + for _, want := range []string{"goversion publish [options]", "-timeout duration", "-major-branch"} { if !strings.Contains(out, want) { t.Errorf("publish help does not contain %q:\n%s", want, out) } @@ -58,7 +58,7 @@ func TestCLIPublishToLocalRemote(t *testing.T) { run(workDir, "git", "tag", "v1.2.3") run(workDir, "git", "remote", "add", "origin", remoteDir) - cmd := exec.Command(os.Args[0], "publish", "-no-release", "-no-proxy") + cmd := exec.Command(os.Args[0], "publish", "-no-release", "-no-proxy", "-major-branch") cmd.Dir = workDir cmd.Env = append(os.Environ(), "GO_HELPER_PROCESS=1") output, err := cmd.CombinedOutput() @@ -69,21 +69,40 @@ func TestCLIPublishToLocalRemote(t *testing.T) { t.Fatalf("unexpected publish output:\n%s", output) } - retry := exec.Command(os.Args[0], "publish", "-no-release", "-no-proxy") + retry := exec.Command(os.Args[0], "publish", "-no-release", "-no-proxy", "-major-branch") retry.Dir = workDir retry.Env = append(os.Environ(), "GO_HELPER_PROCESS=1") retryOutput, err := retry.CombinedOutput() if err != nil { t.Fatalf("publish retry failed: %v\n%s", err, retryOutput) } - if !strings.Contains(string(retryOutput), "Branch: already current") || !strings.Contains(string(retryOutput), "Tag: already current") { + if !strings.Contains(string(retryOutput), "Branch: already current") || !strings.Contains(string(retryOutput), "Tag: already current") || !strings.Contains(string(retryOutput), "Major: v1 already current") { t.Fatalf("publish retry did not reuse completed Git refs:\n%s", retryOutput) } head := run(workDir, "git", "rev-parse", "HEAD") remoteTag := run(workDir, "git", "--git-dir", remoteDir, "rev-parse", "refs/tags/v1.2.3^{commit}") remoteBranch := run(workDir, "git", "--git-dir", remoteDir, "rev-parse", "refs/heads/master") - if remoteTag != head || remoteBranch != head { - t.Fatalf("published refs do not match HEAD %s: tag=%s branch=%s", head, remoteTag, remoteBranch) + remoteMajor := run(workDir, "git", "--git-dir", remoteDir, "rev-parse", "refs/heads/v1") + if remoteTag != head || remoteBranch != head || remoteMajor != head { + t.Fatalf("published refs do not match HEAD %s: tag=%s branch=%s major=%s", head, remoteTag, remoteBranch, remoteMajor) + } + + if err := os.WriteFile(filepath.Join(workDir, "version.go"), []byte("package tool\n\nvar Version = \"1.2.4\"\n"), 0o644); err != nil { + t.Fatal(err) + } + run(workDir, "git", "add", "version.go") + run(workDir, "git", "commit", "-m", "1.2.4") + run(workDir, "git", "tag", "v1.2.4") + advancedHead := run(workDir, "git", "rev-parse", "HEAD") + advance := exec.Command(os.Args[0], "publish", "-no-release", "-no-proxy", "-major-branch") + advance.Dir = workDir + advance.Env = append(os.Environ(), "GO_HELPER_PROCESS=1") + if output, err := advance.CombinedOutput(); err != nil { + t.Fatalf("publish advancement failed: %v\n%s", err, output) + } + advancedMajor := run(workDir, "git", "--git-dir", remoteDir, "rev-parse", "refs/heads/v1") + if advancedMajor != advancedHead { + t.Fatalf("moving major branch was not advanced: got %s, want %s", advancedMajor, advancedHead) } }