From c4bda97030af9cc2f728c47ebc283d2086597b0f Mon Sep 17 00:00:00 2001 From: Wakemeup <57048627+wakemeup0@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:02:44 +0200 Subject: [PATCH] fix(release): tolerate squash-merge subject suffix in resume derivation --- internal/release/release_test.go | 128 +++++++++++++++++++++++++++++++ internal/release/resume.go | 22 +++++- 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/internal/release/release_test.go b/internal/release/release_test.go index e8e8734..bbbb238 100644 --- a/internal/release/release_test.go +++ b/internal/release/release_test.go @@ -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) diff --git a/internal/release/resume.go b/internal/release/resume.go index c9094e7..dd5c17e 100644 --- a/internal/release/resume.go +++ b/internal/release/resume.go @@ -5,6 +5,7 @@ package release import ( "fmt" "path/filepath" + "regexp" "strings" "github.com/openhoo/hooversion/internal/errors" @@ -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. @@ -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 @@ -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( @@ -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 {