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
53 changes: 52 additions & 1 deletion internal/release/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strings"

"github.com/spf13/cobra"

"github.com/stablekernel/cascade/internal/config"
)

// NewCommand creates the manage-release command
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
79 changes: 79 additions & 0 deletions internal/release/command_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package release

import (
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -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")
}
}
171 changes: 171 additions & 0 deletions internal/release/reaper_grammar_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading