Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
35 changes: 31 additions & 4 deletions pkg/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)")
Expand All @@ -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
}

Expand All @@ -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
}
Expand Down
58 changes: 58 additions & 0 deletions pkg/publish_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions pkg/publish_major_branch_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions publish_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down Expand Up @@ -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,
})
Expand All @@ -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
}

Expand Down Expand Up @@ -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 <version-bump>, validate the local release, then run goversion publish.

Expand Down
31 changes: 25 additions & 6 deletions publish_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
}