From e20727b6560b3091a1ec016ac4876b1bc0b1b0d0 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 8 Jul 2026 04:06:25 -0400 Subject: [PATCH] feat(release): reap rc tags per component using the tag grammar The release rc-tag reaper matched rc tags with a hardcoded -rc. pattern and a permissive prefix, so it never reaped a custom tag grammar (those tags accumulated forever) and its superseded-base sweep could enumerate a sibling component's tags. The reaper now takes an optional component tag grammar (threaded via manage-release --component and a WithTagGrammar option on the manager): candidates are parsed through the component's strict spec, so a sibling's tags never parse and are never enumerated, and a custom pre-release token is matched from the grammar rather than a literal -rc. The superseded-base comparison runs only on candidates already parsed under the component spec, so it cannot cross into another namespace. The draft reaper routes through the same grammar-aware parse for consistency. With no component grammar the reaper is behavior-identical to before. Refs #295. Signed-off-by: Joshua Temple --- internal/release/command.go | 53 ++++++- internal/release/command_test.go | 79 ++++++++++ internal/release/reaper_grammar_test.go | 171 ++++++++++++++++++++ internal/release/release.go | 200 +++++++++++++++++++++--- 4 files changed, 482 insertions(+), 21 deletions(-) create mode 100644 internal/release/reaper_grammar_test.go diff --git a/internal/release/command.go b/internal/release/command.go index 410c539a..14fc4849 100644 --- a/internal/release/command.go +++ b/internal/release/command.go @@ -6,6 +6,8 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" ) // NewCommand creates the manage-release command @@ -23,6 +25,9 @@ func NewCommand() *cobra.Command { var deleteTag string var createTag bool var tagOnly bool + var configPath string + var manifestKey string + var component string cmd := &cobra.Command{ Use: "manage-release", @@ -83,7 +88,17 @@ Outputs (to stdout): changelog = strings.TrimSpace(string(content)) } - manager := NewManager(repo, token) + // Scope RC-tag reaping to a declared component's tag namespace when + // --component is set, so a component's publish never reaps a sibling + // component's RC tags and a custom pre-release token is reaped instead + // of accumulating. Without --component the reaper keeps its historical + // permissive single-component behavior. + opts, err := componentReapOptions(configPath, manifestKey, component) + if err != nil { + return err + } + + manager := NewManager(repo, token, opts...) result, err := manager.Manage(Options{ Action: act, Environment: environment, @@ -122,6 +137,9 @@ Outputs (to stdout): cmd.Flags().StringVar(&deleteTag, "delete-tag", "", "Tag to delete after publish (cleanup)") cmd.Flags().BoolVar(&createTag, "create-tag", false, "Create git tag on create action") cmd.Flags().BoolVar(&tagOnly, "tag-only", false, "Create the git tag only and skip creating a draft release (release workflow is the sole release creator)") + cmd.Flags().StringVar(&configPath, "config", "", "Path to CI/CD config file (required with --component to resolve the component tag grammar)") + cmd.Flags().StringVar(&manifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") + cmd.Flags().StringVar(&component, "component", "", "Declared component to scope RC-tag reaping to (multi-component manifests)") _ = cmd.MarkFlagRequired("repo") _ = cmd.MarkFlagRequired("action") @@ -131,6 +149,39 @@ Outputs (to stdout): return cmd } +// componentReapOptions resolves the Manager options that scope RC-tag reaping to +// a declared component. When component is empty the single-component path is +// used and no options are returned, so reaping behavior is unchanged. When a +// component is named the manifest is loaded and the component's strict tag +// grammar is threaded via WithTagGrammar so reaping is exact to that component's +// namespace. A named component requires --config so the grammar can be resolved. +func componentReapOptions(configPath, manifestKey, component string) ([]Option, error) { + if component == "" { + return nil, nil + } + if configPath == "" { + return nil, fmt.Errorf("--config is required with --component to resolve the component tag grammar") + } + if manifestKey == "" { + manifestKey = config.DefaultManifestKey + } + + file, err := config.ParseManifestFile(configPath, manifestKey) + if err != nil { + return nil, fmt.Errorf("loading manifest for component %q: %w", component, err) + } + if file.Config == nil { + return nil, fmt.Errorf("manifest %q has no %q config for component %q", configPath, manifestKey, component) + } + + resolved, err := file.Config.ResolveComponent(component) + if err != nil { + return nil, fmt.Errorf("resolving component %q: %w", component, err) + } + + return []Option{WithTagGrammar(resolved.TagGrammarSpec())}, nil +} + // tagCreatingActions are the actions that materialize a git tag pointing at a // specific commit and therefore require --sha. The remaining actions (lock, // update, delete) resolve an existing release by its tag and treat SHA only as diff --git a/internal/release/command_test.go b/internal/release/command_test.go index b8f9ec37..80510348 100644 --- a/internal/release/command_test.go +++ b/internal/release/command_test.go @@ -1,6 +1,8 @@ package release import ( + "os" + "path/filepath" "strings" "testing" ) @@ -137,3 +139,80 @@ func TestValidateManageReleaseFlags_OtherRequiredFields(t *testing.T) { }) } } + +// TestComponentReapOptions_NoComponentIsSingleComponentPath asserts that without +// a declared component the reaper keeps its single-component behavior: no options +// are threaded, so the resulting Manager carries no grammar (nil) and reaps +// exactly as the historical permissive path did. +func TestComponentReapOptions_NoComponentIsSingleComponentPath(t *testing.T) { + opts, err := componentReapOptions("", "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts != nil { + t.Fatalf("expected no options for the single-component path, got %d", len(opts)) + } + + mgr := NewManager("owner/repo", "tok", opts...) + if mgr.grammar != nil { + t.Fatalf("expected nil grammar (legacy path), got %+v", *mgr.grammar) + } +} + +// TestComponentReapOptions_ComponentRequiresConfig asserts a named component +// without --config is a loud configuration error rather than silently falling +// back to permissive reaping. +func TestComponentReapOptions_ComponentRequiresConfig(t *testing.T) { + _, err := componentReapOptions("", "ci", "api") + if err == nil { + t.Fatal("expected an error when --component is set without --config") + } + if !strings.Contains(err.Error(), "--config is required") { + t.Fatalf("expected a --config-required error, got %q", err.Error()) + } +} + +// TestComponentReapOptions_ThreadsStrictComponentGrammar asserts that a declared +// component's resolved grammar reaches the Manager: the option threads a strict +// grammar carrying the component's prefix and custom pre-release token, so the +// reaper is scoped to that component's namespace. +func TestComponentReapOptions_ThreadsStrictComponentGrammar(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + manifest := `ci: + config: + trunk_branch: main + environments: [dev, prod] + components: + api: + path: services/api + tag_prefix: api- + tag_grammar: + prerelease_token: beta +` + if err := os.WriteFile(path, []byte(manifest), 0o600); err != nil { + t.Fatalf("writing manifest: %v", err) + } + + opts, err := componentReapOptions(path, "ci", "api") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts) != 1 { + t.Fatalf("expected exactly one option, got %d", len(opts)) + } + + mgr := NewManager("owner/repo", "tok", opts...) + if mgr.grammar == nil { + t.Fatal("expected a threaded component grammar, got nil") + } + if got := mgr.grammar.Prefix; got != "api-" { + t.Errorf("grammar prefix = %q, want api-", got) + } + if got := mgr.grammar.PreReleaseToken; got != "beta" { + t.Errorf("grammar pre-release token = %q, want beta", got) + } + if !mgr.grammar.StrictPrefix { + t.Error("expected StrictPrefix to be forced on for a declared component") + } +} diff --git a/internal/release/reaper_grammar_test.go b/internal/release/reaper_grammar_test.go new file mode 100644 index 00000000..c414acd9 --- /dev/null +++ b/internal/release/reaper_grammar_test.go @@ -0,0 +1,171 @@ +package release + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/taggrammar" +) + +// newReapTestManager builds a Manager whose tag-list endpoint returns listedTags +// and whose git-ref DELETE endpoint records the deleted tag names into the +// returned slice pointer. Options thread the per-component grammar under test. +func newReapTestManager(t *testing.T, listedTags []string, opts ...Option) (*Manager, *[]string) { + t.Helper() + deleted := &[]string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/git/refs/tags" { + refs := make([]map[string]string, 0, len(listedTags)) + for _, tag := range listedTags { + refs = append(refs, map[string]string{"ref": "refs/tags/" + tag}) + } + _ = json.NewEncoder(w).Encode(refs) + return + } + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/git/refs/tags/") { + tag := strings.TrimPrefix(r.URL.Path, "/repos/owner/repo/git/refs/tags/") + *deleted = append(*deleted, tag) + w.WriteHeader(http.StatusNoContent) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + mgr := NewManagerWithURL("owner/repo", "test-token", server.URL, opts...) + return mgr, deleted +} + +// componentSpec returns a strict per-component grammar with the given prefix and +// pre-release token, mirroring ResolvedComponent.TagGrammarSpec (StrictPrefix on). +func componentSpec(prefix, token string) taggrammar.Spec { + spec := taggrammar.Default() + spec.Prefix = prefix + spec.PreReleaseToken = token + spec.StrictPrefix = true + return spec +} + +// TestCleanupRCTags_ComponentNamespaceIsolation proves that a component's reaper, +// threaded with its strict tag grammar, reaps only its own RC tags and never a +// sibling component's tags, even when the sibling's base version is lower than the +// published version (the superseded-base enumeration must not cross namespaces) +// and even when the sibling uses a different pre-release token. +func TestCleanupRCTags_ComponentNamespaceIsolation(t *testing.T) { + listed := []string{ + // Component A ("api-") - the publishing component. + "api-0.9.0-rc.0", // superseded earlier base - reap + "api-1.0.0-rc.0", // below published base - reap + "api-1.0.1-rc.0", // equal to published base - reap + "api-1.0.1-rc.3", // equal to published base - reap + "api-1.1.0-rc.0", // higher base, future work - preserve + // Component B ("web-") default token, lower and equal bases - must be + // preserved despite being <= the published numeric base. + "web-0.5.0-rc.0", + "web-1.0.0-rc.0", + "web-1.0.1-rc.0", + // Component C ("svc-") custom "beta" token, lower base - must be preserved. + "svc-0.8.0-beta.0", + "svc-1.0.0-beta.0", + } + + mgr, deleted := newReapTestManager(t, listed, WithTagGrammar(componentSpec("api-", "rc"))) + + err := mgr.cleanupRCTags("api-1.0.1") + require.NoError(t, err) + + assert.ElementsMatch(t, []string{ + "api-0.9.0-rc.0", + "api-1.0.0-rc.0", + "api-1.0.1-rc.0", + "api-1.0.1-rc.3", + }, *deleted, "only component A's RC tags at or below the published base are reaped") + + // No sibling tag is ever touched, including lower-base siblings. + for _, sibling := range []string{ + "web-0.5.0-rc.0", "web-1.0.0-rc.0", "web-1.0.1-rc.0", + "svc-0.8.0-beta.0", "svc-1.0.0-beta.0", "api-1.1.0-rc.0", + } { + assert.NotContains(t, *deleted, sibling) + } +} + +// TestCleanupRCTags_CustomGrammarIsReaped proves the accumulation bug is fixed: a +// component whose tag grammar uses a non-default pre-release token ("beta") has +// its RC tags matched and reaped, where the hardcoded "-rc." matcher never would. +func TestCleanupRCTags_CustomGrammarIsReaped(t *testing.T) { + listed := []string{ + "svc-1.0.0-beta.0", + "svc-1.0.0-beta.1", + "svc-1.0.1-beta.0", + "svc-1.1.0-beta.0", // higher base - preserve + } + + mgr, deleted := newReapTestManager(t, listed, WithTagGrammar(componentSpec("svc-", "beta"))) + + err := mgr.cleanupRCTags("svc-1.0.1") + require.NoError(t, err) + + assert.ElementsMatch(t, []string{ + "svc-1.0.0-beta.0", + "svc-1.0.0-beta.1", + "svc-1.0.1-beta.0", + }, *deleted, "custom-token RC tags at or below the published base are reaped") + assert.NotContains(t, *deleted, "svc-1.1.0-beta.0") +} + +// TestCleanupRCTags_CustomGrammarSkipsHotfixVariants proves a nested hotfix +// variant under a custom grammar is not treated as a plain RC tag, matching the +// default-grammar contract that hotfix tags are reaped by the hotfix-rejoin path. +func TestCleanupRCTags_CustomGrammarSkipsHotfixVariants(t *testing.T) { + listed := []string{ + "svc-1.0.1-beta.0", + "svc-1.0.1-beta.1.hotfix.1", // hotfix variant - preserve + } + + mgr, deleted := newReapTestManager(t, listed, WithTagGrammar(componentSpec("svc-", "beta"))) + + err := mgr.cleanupRCTags("svc-1.0.1") + require.NoError(t, err) + + assert.Equal(t, []string{"svc-1.0.1-beta.0"}, *deleted) + assert.NotContains(t, *deleted, "svc-1.0.1-beta.1.hotfix.1") +} + +// TestCleanupRCTags_NoGrammarMatchesLegacyReaper proves the single-component (no +// component context) path is behavior-identical to the historical reaper: the +// permissive prefix and hardcoded "-rc." matching reap exactly the same tags as +// before threading was introduced. +func TestCleanupRCTags_NoGrammarMatchesLegacyReaper(t *testing.T) { + listed := []string{ + "v0.9.0-rc.0", // below published base - reap + "v1.0.0-rc.0", // superseded earlier base - reap + "v1.0.1-rc.0", // equal to published base - reap + "v1.0.1-rc.2", // equal to published base - reap + "v1.1.0-rc.0", // higher base - preserve + "rel-1.0.0-rc.0", // different prefix - preserve + "v1.0.1-rc.1.hotfix.1", // hotfix variant - preserve + } + + // No WithTagGrammar option: the legacy permissive path. + mgr, deleted := newReapTestManager(t, listed) + + err := mgr.cleanupRCTags("v1.0.1") + require.NoError(t, err) + + assert.ElementsMatch(t, []string{ + "v0.9.0-rc.0", + "v1.0.0-rc.0", + "v1.0.1-rc.0", + "v1.0.1-rc.2", + }, *deleted) + assert.NotContains(t, *deleted, "v1.1.0-rc.0") + assert.NotContains(t, *deleted, "rel-1.0.0-rc.0") + assert.NotContains(t, *deleted, "v1.0.1-rc.1.hotfix.1") +} diff --git a/internal/release/release.go b/internal/release/release.go index c223b44f..a45964f1 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/stablekernel/cascade/internal/taggrammar" "github.com/stablekernel/cascade/internal/version" ) @@ -38,38 +39,71 @@ type Manager struct { baseURL string token string repo string + // grammar, when set, is the resolved per-component tag grammar the RC-tag + // reaper parses tags through. It is nil for the single-component (default) + // path, which keeps the historical permissive matching so single-component + // reaping is behavior-identical to before component threading existed. A + // declared component supplies a strict-prefix grammar via WithTagGrammar so + // reaping stays exact to that component's tag namespace. + grammar *taggrammar.Spec // sleepFn is called between retry attempts in findReleaseByTagOrSHA to give // GitHub's release-list endpoint time to reflect a recently created draft. // Defaults to time.Sleep; tests inject a no-op to keep test runs fast. sleepFn func(time.Duration) } +// Option configures a Manager at construction. Options follow the functional +// options pattern so new per-component capability is additive and never a +// breaking change to the constructor signature. +type Option func(*Manager) + +// WithTagGrammar scopes the Manager's RC-tag reaper to a component's tag +// namespace by parsing candidate tags through spec instead of the historical +// permissive matcher. spec is expected to be a strict-prefix grammar (see +// config.ResolvedComponent.TagGrammarSpec), so a component reaps only its own RC +// tags and never a sibling component's. Omitting this option keeps the +// single-component permissive behavior unchanged. +func WithTagGrammar(spec taggrammar.Spec) Option { + return func(m *Manager) { + s := spec + m.grammar = &s + } +} + // NewManager creates a new release manager. // It respects GITHUB_API_URL for GitHub Enterprise or test environments. -func NewManager(repo, token string) *Manager { +func NewManager(repo, token string, opts ...Option) *Manager { baseURL := "https://api.github.com" if envURL := os.Getenv("GITHUB_API_URL"); envURL != "" { baseURL = strings.TrimSuffix(envURL, "/") } - return &Manager{ + m := &Manager{ client: &http.Client{}, baseURL: baseURL, token: token, repo: repo, sleepFn: time.Sleep, } + for _, opt := range opts { + opt(m) + } + return m } // NewManagerWithURL creates a release manager with a custom API URL. // Use this for testing or when GITHUB_API_URL isn't set. -func NewManagerWithURL(repo, token, baseURL string) *Manager { - return &Manager{ +func NewManagerWithURL(repo, token, baseURL string, opts ...Option) *Manager { + m := &Manager{ client: &http.Client{}, baseURL: strings.TrimSuffix(baseURL, "/"), token: token, repo: repo, sleepFn: time.Sleep, } + for _, opt := range opts { + opt(m) + } + return m } // isGitHubHost reports whether the API base URL points at GitHub (github.com or @@ -235,9 +269,14 @@ func (m *Manager) deleteGitTag(tagName string) error { // // This is called after publishing a release to clean up the RC tags. func (m *Manager) cleanupRCTags(publishedTag string) error { - publishedPrefix, published, err := splitVersionPrefix(publishedTag) + // Build the predicate that decides which listed RC tags this publish + // supersedes. When a per-component grammar is threaded (WithTagGrammar) the + // predicate parses candidates through that strict grammar so reaping stays + // exact to the component's namespace; otherwise it reproduces the historical + // permissive matching byte for byte. + supersedes, err := m.supersededRCMatcher(publishedTag) if err != nil { - return fmt.Errorf("parsing published version %q: %w", publishedTag, err) + return err } // List all tags in the repository @@ -246,33 +285,115 @@ func (m *Manager) cleanupRCTags(publishedTag string) error { return fmt.Errorf("listing tags: %w", err) } - // Reap every RC tag whose base is <= the published version (same prefix). for _, tag := range tags { + if !supersedes(tag) { + continue + } + fmt.Printf("Cleaning up RC tag: %s\n", tag) + if err := m.deleteGitTag(tag); err != nil { + fmt.Printf("Warning: failed to delete RC tag %s: %v\n", tag, err) + // Continue with other tags + } + } + + return nil +} + +// supersededRCMatcher returns a predicate reporting whether a listed tag is a +// plain RC tag in the published tag's namespace whose base version is at or below +// it, and therefore superseded by the publish. When the Manager carries a +// per-component grammar the match is strict to that component's prefix and +// pre-release token; otherwise it is the historical permissive match. It errors +// only when the published tag itself is not parseable under the active grammar, +// so a misconfigured publish fails loudly rather than reaping nothing. +func (m *Manager) supersededRCMatcher(publishedTag string) (func(tag string) bool, error) { + if m.grammar != nil { + return strictSupersededRCMatcher(*m.grammar, publishedTag) + } + return legacySupersededRCMatcher(publishedTag) +} + +// legacySupersededRCMatcher reproduces the historical permissive reaper: it +// splits the numeric core off any prefix, requires a string-equal prefix, and +// reaps every plain RC tag whose base is at or below the published base. It is +// the single-component path and is behavior-identical to the pre-threading code. +func legacySupersededRCMatcher(publishedTag string) (func(string) bool, error) { + publishedPrefix, published, err := splitVersionPrefix(publishedTag) + if err != nil { + return nil, fmt.Errorf("parsing published version %q: %w", publishedTag, err) + } + return func(tag string) bool { tagBase, _, ok := parseRCTag(tag) if !ok { - continue // Not a plain RC tag (or a hotfix variant) + return false // Not a plain RC tag (or a hotfix variant) } basePrefix, base, err := splitVersionPrefix(tagBase) if err != nil { - continue // Unparseable base - leave it alone + return false // Unparseable base - leave it alone } // A different prefix names a separate release line; never compare across // prefixes since version.Compare is prefix-agnostic. if basePrefix != publishedPrefix { - continue + return false } // Preserve bases strictly greater than the published version (future work). - if base.Compare(published) > 0 { - continue + return base.Compare(published) <= 0 + }, nil +} + +// strictSupersededRCMatcher parses candidates through a component's strict tag +// grammar. Because the grammar's prefix is matched literally, a sibling +// component's tags never parse and so are never enumerated, let alone reaped; +// this closes the cross-namespace hazard the permissive string-prefix compare +// left open. A custom pre-release token is matched because the token comes from +// the grammar rather than a hardcoded "-rc.", so custom-grammar RC tags reap +// instead of accumulating. +func strictSupersededRCMatcher(spec taggrammar.Spec, publishedTag string) (func(string) bool, error) { + published, ok := spec.Parse(publishedTag) + if !ok { + return nil, fmt.Errorf("published version %q is not a valid tag under the component tag grammar", publishedTag) + } + return func(tag string) bool { + p, ok := spec.Parse(tag) + if !ok { + return false // Foreign namespace or foreign shape - not ours. } - fmt.Printf("Cleaning up RC tag: %s\n", tag) - if err := m.deleteGitTag(tag); err != nil { - fmt.Printf("Warning: failed to delete RC tag %s: %v\n", tag, err) - // Continue with other tags + if p.PreRelease < 0 { + return false // A published base tag, not an RC tag. + } + if p.Hotfix >= 0 { + return false // Hotfix variant - reaped by the hotfix-rejoin path. } + // Preserve bases strictly greater than the published version (future work). + return compareParsedBase(p, published) <= 0 + }, nil +} + +// compareParsedBase returns -1, 0, or +1 comparing only the numeric +// major.minor.patch cores of a and b, ignoring pre-release and hotfix segments. +// It mirrors the base comparison the permissive path performs via version.Compare +// on two pre-release-stripped versions. +func compareParsedBase(a, b taggrammar.Parsed) int { + if c := compareInt(a.Major, b.Major); c != 0 { + return c + } + if c := compareInt(a.Minor, b.Minor); c != 0 { + return c } + return compareInt(a.Patch, b.Patch) +} - return nil +// compareInt returns -1, 0, or +1 reporting whether a is less than, equal to, or +// greater than b. +func compareInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } } // splitVersionPrefix splits a base version tag into its tag prefix and the @@ -446,12 +567,51 @@ func parseRCTag(tag string) (baseVersion string, rcNumber int, ok bool) { return matches[1], rc, true } +// parseRCTag extracts the base version and RC number from an RC tag under the +// Manager's active grammar. With a per-component grammar (WithTagGrammar) it +// parses strictly so a sibling component's tags and a custom pre-release token +// are handled; without one it falls back to the historical permissive +// package-level parseRCTag, keeping single-component behavior identical. +func (m *Manager) parseRCTag(tag string) (baseVersion string, rcNumber int, ok bool) { + if m.grammar != nil { + return parseRCTagStrict(*m.grammar, tag) + } + return parseRCTag(tag) +} + +// isRCTag reports whether tag is a plain RC tag under the Manager's active +// grammar. +func (m *Manager) isRCTag(tag string) bool { + _, _, ok := m.parseRCTag(tag) + return ok +} + +// parseRCTagStrict extracts the base version (including its literal prefix) and +// RC number from tag under spec. A tag that is not a version tag, that carries no +// pre-release, or that is a nested hotfix variant is rejected, matching the plain +// RC-tag contract of the permissive parseRCTag. The base is rendered through the +// grammar so it carries the component's prefix. +func parseRCTagStrict(spec taggrammar.Spec, tag string) (baseVersion string, rcNumber int, ok bool) { + p, matched := spec.Parse(tag) + if !matched || p.PreRelease < 0 || p.Hotfix >= 0 { + return "", -1, false + } + base := spec.Format(taggrammar.Parsed{ + Major: p.Major, + Minor: p.Minor, + Patch: p.Patch, + PreRelease: -1, + Hotfix: -1, + }) + return base, p.PreRelease, true +} + // cleanupStaleDrafts deletes draft releases with the SAME base version but LOWER RC number. // For example, when creating v1.3.0-rc.3, it deletes v1.3.0-rc.0, v1.3.0-rc.1, v1.3.0-rc.2. // Drafts with different base versions (e.g., v1.2.0-rc.5) are preserved - they represent // work that has been promoted to a different environment. func (m *Manager) cleanupStaleDrafts(environment, currentTag string) error { - currentBase, currentRC, ok := parseRCTag(currentTag) + currentBase, currentRC, ok := m.parseRCTag(currentTag) if !ok { // Not an RC tag, nothing to clean up return nil @@ -470,12 +630,12 @@ func (m *Manager) cleanupStaleDrafts(environment, currentTag string) error { // Get the tag to check - prefer tag_name, fallback to name tagToCheck := release.TagName - if !isRCTag(tagToCheck) && isRCTag(release.Name) { + if !m.isRCTag(tagToCheck) && m.isRCTag(release.Name) { tagToCheck = release.Name } // Parse the release tag - releaseBase, releaseRC, ok := parseRCTag(tagToCheck) + releaseBase, releaseRC, ok := m.parseRCTag(tagToCheck) if !ok { // Not an RC tag, skip continue