Skip to content
Merged
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
128 changes: 128 additions & 0 deletions internal/release/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,134 @@ func TestResumesSquashMergedReleaseCommitAndCreatesMissingTag(t *testing.T) {
}
}

// amendSubjectSuffix rewrites the HEAD commit subject to carry suffix,
// simulating the " (#N)" GitHub appends when a protected release pull
// request is squash-merged.
func amendSubjectSuffix(t *testing.T, cwd, suffix string) {
t.Helper()
message := gitOut(t, cwd, "log", "-1", "--format=%B")
lines := strings.SplitN(message, "\n", 2)
lines[0] += suffix
gitOut(t, cwd, "commit", "--amend", "-m", strings.Join(lines, "\n"))
}

func TestResumesSquashMergedReleaseCommitWithPullRequestSuffix(t *testing.T) {
cwd := seedAppRepo(t)
config := singleAppConfig(false)

plan, err := CreatePlanForTest(cwd, config)
if err != nil {
t.Fatal(err)
}
first, err := Execute(cwd, config, plan, Options{NoPushSet: true, NoGitHubSet: true})
if err != nil || !first.Published {
t.Fatalf("first run: %v %+v", err, first)
}
gitOut(t, cwd, "tag", "--delete", "v1.0.1")
amendSubjectSuffix(t, cwd, " (#106)")
releaseHead := gitOut(t, cwd, "rev-parse", "HEAD")
commitCount := gitOut(t, cwd, "rev-list", "--count", "HEAD")
rerunPlan, err := CreatePlanForTest(cwd, config)
if err != nil {
t.Fatal(err)
}
run, err := Execute(cwd, config, rerunPlan, Options{NoPushSet: true, NoGitHubSet: true})
if err != nil {
t.Fatal(err)
}
if !run.Published || len(run.Plan.Releases) != 1 {
t.Fatalf("suffixed resume result wrong: %+v", run)
}
if got := gitOut(t, cwd, "rev-list", "-n", "1", "v1.0.1"); got != releaseHead {
t.Fatalf("recreated tag points to %q, want %q", got, releaseHead)
}
if got := gitOut(t, cwd, "rev-list", "--count", "HEAD"); got != commitCount {
t.Fatalf("resume created extra commits: %s -> %s", commitCount, got)
}
}

func TestResumesMultiPackageReleaseCommitWithPullRequestSuffix(t *testing.T) {
cwd := makeRepo(t)
writeFile(t, filepath.Join(cwd, "packages", "one", "package.json"), "{\"name\": \"one\", \"version\": \"0.1.0\"}\n")
writeFile(t, filepath.Join(cwd, "packages", "two", "package.json"), "{\"name\": \"two\", \"version\": \"0.3.0\"}\n")
commitAll(t, cwd, "initial import")
gitOut(t, cwd, "tag", "-a", "one@v0.1.0", "-m", "one@v0.1.0")
gitOut(t, cwd, "tag", "-a", "two@v0.3.0", "-m", "two@v0.3.0")
writeFile(t, filepath.Join(cwd, "packages", "one", "a.ts"), "a\n")
writeFile(t, filepath.Join(cwd, "packages", "two", "b.ts"), "b\n")
commitAll(t, cwd, "feat: grow both packages")

config := &types.NormalizedConfig{
Branches: []string{"main"},
TagFormat: "v${version}",
IndependentTagFormat: "${name}@v${version}",
Packages: []types.NormalizedPackageConfig{
nodePkg("one", "packages/one", "packages/one/package.json"),
nodePkg("two", "packages/two", "packages/two/package.json"),
},
GitHub: types.GitHubSettings{Enabled: false, Releases: true, ApiUrl: "https://api.github.com"},
OutputDir: ".hooversion",
Push: false,
}
plan, err := CreatePlanForTest(cwd, config)
if err != nil {
t.Fatal(err)
}
first, err := Execute(cwd, config, plan, Options{NoPushSet: true, NoGitHubSet: true})
if err != nil || !first.Published {
t.Fatalf("first run: %v %+v", err, first)
}
gitOut(t, cwd, "tag", "--delete", "one@v0.2.0")
gitOut(t, cwd, "tag", "--delete", "two@v0.4.0")
amendSubjectSuffix(t, cwd, " (#42)")
releaseHead := gitOut(t, cwd, "rev-parse", "HEAD")
commitCount := gitOut(t, cwd, "rev-list", "--count", "HEAD")

rerunPlan, err := CreatePlanForTest(cwd, config)
if err != nil {
t.Fatal(err)
}
run, err := Execute(cwd, config, rerunPlan, Options{NoPushSet: true, NoGitHubSet: true})
if err != nil {
t.Fatal(err)
}
if !run.Published || len(run.Plan.Releases) != 2 {
t.Fatalf("suffixed multi-package resume result wrong: %+v", run)
}
for _, tag := range []string{"one@v0.2.0", "two@v0.4.0"} {
if got := gitOut(t, cwd, "rev-list", "-n", "1", tag); got != releaseHead {
t.Fatalf("recreated tag %s points to %q, want %q", tag, got, releaseHead)
}
}
if got := gitOut(t, cwd, "rev-list", "--count", "HEAD"); got != commitCount {
t.Fatalf("resume created extra commits: %s -> %s", commitCount, got)
}
}

func TestRejectsReleaseCommitWithNonNumericSubjectSuffix(t *testing.T) {
cwd := seedAppRepo(t)
config := singleAppConfig(false)

plan, err := CreatePlanForTest(cwd, config)
if err != nil {
t.Fatal(err)
}
first, err := Execute(cwd, config, plan, Options{NoPushSet: true, NoGitHubSet: true})
if err != nil || !first.Published {
t.Fatalf("first run: %v %+v", err, first)
}
gitOut(t, cwd, "tag", "--delete", "v1.0.1")
amendSubjectSuffix(t, cwd, " (alpha)")

derived, err := DeriveResumable(cwd, config)
if err != nil {
t.Fatal(err)
}
if derived != nil {
t.Fatalf("non-numeric suffix must not derive a resumable plan: %+v", derived)
}
}

func TestForeignUntrackedBlocksButManagedOutputsPreserved(t *testing.T) {
cwd := seedAppRepo(t)
config := singleAppConfig(false)
Expand Down
22 changes: 20 additions & 2 deletions internal/release/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package release
import (
"fmt"
"path/filepath"
"regexp"
"strings"

"github.com/openhoo/hooversion/internal/errors"
Expand All @@ -15,6 +16,11 @@ import (
"github.com/openhoo/hooversion/internal/types"
)

// squashMergeSuffix matches the " (#N)" GitHub appends to a squash-merged
// pull request subject. Protected-branch release commits arrive on main with
// that suffix; resume derivation strips it before parsing and comparison.
var squashMergeSuffix = regexp.MustCompile(` \(#\d+\)$`)

// DeriveResumable reconstructs a ReleasePlan from an already-executed release
// commit (manifest already bumped). The release tag may either already point at
// HEAD or still be absent after a protected-branch release PR was squash-merged.
Expand Down Expand Up @@ -49,6 +55,7 @@ func DeriveResumableWithEnv(cwd string, config *types.NormalizedConfig, baseEnv
return nil, err
}
subject, body := splitSubjectBody(message)
subject = squashMergeSuffix.ReplaceAllString(subject, "")
const prefix = "chore(release): "
if !strings.HasPrefix(subject, prefix) {
return nil, nil
Expand Down Expand Up @@ -153,12 +160,23 @@ func DeriveResumableWithEnv(cwd string, config *types.NormalizedConfig, baseEnv
if err != nil {
return nil, err
}
if headMessage != CommitMessage(reconstructed) {
if !releaseMessageMatches(headMessage, CommitMessage(reconstructed)) {
return nil, nil
}
return reconstructed, nil
}

// releaseMessageMatches reports whether a HEAD commit message equals the
// expected release message, tolerating the " (#N)" subject suffix GitHub
// adds when a protected release pull request is squash-merged.
func releaseMessageMatches(message, expected string) bool {
if message == expected {
return true
}
subject, body := splitSubjectBody(message)
return squashMergeSuffix.ReplaceAllString(subject, "")+"\n\n"+body == expected
}

// inferReleaseTransition probes candidate previous versions (major, minor,
// patch) whose tags exist to recover the bump that produced nextVersion.
func inferReleaseTransition(
Expand Down Expand Up @@ -252,7 +270,7 @@ func isResumableReleaseWithEnv(cwd string, effective *types.ReleasePlan, baseEnv
return false
}
message, err := git.CommitMessageWithEnv(cwd, "HEAD", baseEnv)
if err != nil || message != CommitMessage(effective) {
if err != nil || !releaseMessageMatches(message, CommitMessage(effective)) {
return false
}
for _, release := range effective.Releases {
Expand Down
Loading