From 2719323236ab94cc38e0562738aa2f02ef166fc7 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 10 Jun 2026 23:39:57 -0400 Subject: [PATCH] feat(promote): rejoin and clean up diverged envs on inbound promotion When a promotion into a diverged environment passes the patch-containment gate and finalizes, the environment rejoins trunk: clear ref/base_sha/patches, delete the env/ integration branch, and remove the hotfix tags and release drafts minted for that base. Add a status consistency check that flags env/* branches with no matching manifest divergence (orphan integration branches). Cleanup is gated on prior divergence and injected through a no-op LifecycleCleaner, so a normal promotion into a non-diverged environment touches none of it. Adds git helpers for remote branch/tag deletion and remote branch listing. Signed-off-by: Joshua Temple --- internal/git/delete_test.go | 125 ++++++++++++ internal/git/git.go | 63 ++++++ internal/hotfix/lifecycle.go | 70 +++++++ internal/hotfix/lifecycle_test.go | 129 ++++++++++++ internal/promote/command_finalize.go | 15 +- internal/promote/finalize.go | 75 ++++++- internal/promote/rejoin.go | 145 +++++++++++++ internal/promote/rejoin_integration_test.go | 173 ++++++++++++++++ internal/promote/rejoin_test.go | 214 ++++++++++++++++++++ internal/status/command.go | 1 + internal/status/consistency.go | 79 ++++++++ internal/status/consistency_test.go | 81 ++++++++ 12 files changed, 1162 insertions(+), 8 deletions(-) create mode 100644 internal/git/delete_test.go create mode 100644 internal/hotfix/lifecycle.go create mode 100644 internal/hotfix/lifecycle_test.go create mode 100644 internal/promote/rejoin.go create mode 100644 internal/promote/rejoin_integration_test.go create mode 100644 internal/promote/rejoin_test.go create mode 100644 internal/status/consistency.go create mode 100644 internal/status/consistency_test.go diff --git a/internal/git/delete_test.go b/internal/git/delete_test.go new file mode 100644 index 00000000..88586fcf --- /dev/null +++ b/internal/git/delete_test.go @@ -0,0 +1,125 @@ +package git + +import ( + "os" + "testing" +) + +// cloneWithOrigin creates a clone of dir whose origin is dir, chdirs into the +// clone for the test, fetches origin, and returns the clone path. +func cloneWithOrigin(t *testing.T, dir string) string { + t.Helper() + clone := t.TempDir() + runGit(t, "clone", dir, clone) + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(clone); err != nil { + t.Fatalf("chdir clone: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + runGit(t, "fetch", "origin") + return clone +} + +func TestDeleteRemoteBranch(t *testing.T) { + dir := newScratchRepo(t) + commitFile(t, "a.txt", "one", "first commit") + runGit(t, "branch", "-M", "main") + runGit(t, "branch", "env/test") + + cloneWithOrigin(t, dir) + + exists, err := BranchExists("origin", "env/test") + if err != nil { + t.Fatalf("BranchExists: %v", err) + } + if !exists { + t.Fatalf("precondition: env/test should exist on origin") + } + + if err := DeleteRemoteBranch("origin", "env/test"); err != nil { + t.Fatalf("DeleteRemoteBranch: %v", err) + } + + runGit(t, "fetch", "origin", "--prune") + exists, err = BranchExists("origin", "env/test") + if err != nil { + t.Fatalf("BranchExists after delete: %v", err) + } + if exists { + t.Fatalf("env/test should be deleted on origin") + } + + // Deleting an already-absent branch is a no-op success (idempotent). + if err := DeleteRemoteBranch("origin", "env/test"); err != nil { + t.Fatalf("DeleteRemoteBranch on missing branch should be a no-op: %v", err) + } +} + +func TestDeleteRemoteTag(t *testing.T) { + dir := newScratchRepo(t) + commitFile(t, "a.txt", "one", "first commit") + runGit(t, "branch", "-M", "main") + runGit(t, "tag", "v1.4.0-rc.2.hotfix.1") + + cloneWithOrigin(t, dir) + runGit(t, "fetch", "origin", "--tags") + + tags, err := ListTags() + if err != nil { + t.Fatalf("ListTags: %v", err) + } + if !contains(tags, "v1.4.0-rc.2.hotfix.1") { + t.Fatalf("precondition: hotfix tag should be present, got %v", tags) + } + + if err := DeleteRemoteTag("origin", "v1.4.0-rc.2.hotfix.1"); err != nil { + t.Fatalf("DeleteRemoteTag: %v", err) + } + + // Deleting an already-absent tag is a no-op success (idempotent). + if err := DeleteRemoteTag("origin", "v1.4.0-rc.2.hotfix.1"); err != nil { + t.Fatalf("DeleteRemoteTag on missing tag should be a no-op: %v", err) + } +} + +func TestListRemoteBranches(t *testing.T) { + dir := newScratchRepo(t) + commitFile(t, "a.txt", "one", "first commit") + runGit(t, "branch", "-M", "main") + runGit(t, "branch", "env/test") + runGit(t, "branch", "env/uat") + + cloneWithOrigin(t, dir) + + branches, err := ListRemoteBranches("origin") + if err != nil { + t.Fatalf("ListRemoteBranches: %v", err) + } + + for _, want := range []string{"main", "env/test", "env/uat"} { + if !contains(branches, want) { + t.Errorf("expected branch %q in %v", want, branches) + } + } + // The remote HEAD pointer must not leak through as a branch name. + if contains(branches, "HEAD") { + t.Errorf("HEAD should not be returned as a branch: %v", branches) + } +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/internal/git/git.go b/internal/git/git.go index a3ce46c8..1463af18 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -292,6 +292,69 @@ func RemoteBranchSHA(remote, name string) (string, error) { return strings.TrimSpace(string(output)), nil } +// ListRemoteBranches returns the branch names known for the given remote via the +// remote-tracking refs refs/remotes//*. The remote prefix and the symbolic +// HEAD pointer are stripped, so "refs/remotes/origin/env/test" is returned as +// "env/test". The remote must have been fetched first; a shallow or partial fetch +// that omits branches will leave them out of the result. +func ListRemoteBranches(remote string) ([]string, error) { + prefix := fmt.Sprintf("refs/remotes/%s/", remote) + cmd := exec.Command("git", "for-each-ref", "--format=%(refname)", prefix) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git for-each-ref %s: %w", prefix, err) + } + + var branches []string + for _, ref := range parseLines(output) { + name := strings.TrimPrefix(ref, prefix) + if name == "" || name == "HEAD" { + continue + } + branches = append(branches, name) + } + return branches, nil +} + +// DeleteRemoteBranch deletes the named branch on the given remote by running +// "git push --delete ". Deleting a branch that does not exist on +// the remote is treated as success so the operation is idempotent: re-running a +// rejoin cleanup after a partial failure does not error on an already-deleted +// branch. +func DeleteRemoteBranch(remote, name string) error { + cmd := exec.Command("git", "push", remote, "--delete", name) + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + if remoteRefAlreadyGone(out) { + return nil + } + return fmt.Errorf("git push %s --delete %s: %w\n%s", remote, name, err, out) +} + +// DeleteRemoteTag deletes the named tag on the given remote by running +// "git push --delete refs/tags/". Deleting a tag that does not +// exist on the remote is treated as success so the operation is idempotent. +func DeleteRemoteTag(remote, name string) error { + cmd := exec.Command("git", "push", remote, "--delete", "refs/tags/"+name) + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + if remoteRefAlreadyGone(out) { + return nil + } + return fmt.Errorf("git push %s --delete refs/tags/%s: %w\n%s", remote, name, err, out) +} + +// remoteRefAlreadyGone reports whether a failed delete-push is because the ref +// does not exist on the remote, which we treat as success. Git emits "remote ref +// does not exist" (newer) or "unable to delete ... remote ref does not exist". +func remoteRefAlreadyGone(out []byte) bool { + return strings.Contains(string(out), "remote ref does not exist") +} + // GetLatestReleaseTag returns the most recent non-prerelease tag (no -rc suffix). // This is used to find the base version for calculating next release versions. func GetLatestReleaseTag(prefix string) (string, string, error) { diff --git a/internal/hotfix/lifecycle.go b/internal/hotfix/lifecycle.go new file mode 100644 index 00000000..46c5fdb9 --- /dev/null +++ b/internal/hotfix/lifecycle.go @@ -0,0 +1,70 @@ +package hotfix + +import ( + "strings" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/version" +) + +// EnvBranchPrefix is the prefix of the per-environment integration branches a +// hotfix creates (for example env/test). A branch carrying this prefix exists +// only while its environment is diverged; once the env rejoins trunk the branch +// is deleted. +const EnvBranchPrefix = "env/" + +// OrphanEnvBranches returns the env/* branches in branches that have no matching +// divergence in state. A branch env/ is healthy only while state[] +// reports IsDiverged(); a branch with no diverged env behind it is an orphan +// left over from an interrupted hotfix or manual meddling and should be flagged. +// +// Non env/* branches are ignored. The returned slice preserves the input order +// and is nil when nothing is orphaned, so callers can treat a nil result as +// "consistent". +func OrphanEnvBranches(branches []string, state map[string]*config.EnvState) []string { + var orphans []string + for _, branch := range branches { + if !strings.HasPrefix(branch, EnvBranchPrefix) { + continue + } + env := strings.TrimPrefix(branch, EnvBranchPrefix) + st := state[env] + if st != nil && st.IsDiverged() { + continue + } + orphans = append(orphans, branch) + } + return orphans +} + +// HotfixTagsForBase returns the hotfix tags in tags that belong to the rc base +// of baseVersion. A hotfix tag has the dotted shape vX.Y.Z-rc.N.hotfix.M; it +// shares the rc base (vX.Y.Z-rc.N) of the version the environment held while +// diverged. The RC-shaped cleanup in internal/release deliberately cannot see +// these tags (it matches only ^vX.Y.Z-rc.N$), so divergence-end cleanup must +// collect them explicitly. +// +// baseVersion may itself be a hotfix version (vX.Y.Z-rc.N.hotfix.M); it is +// normalized to its rc base before matching. Tags that do not parse, are not +// hotfix tags, or belong to a different rc base are excluded. The result is nil +// when nothing matches. +func HotfixTagsForBase(baseVersion string, tags []string) []string { + base, err := version.Parse(baseVersion) + if err != nil || base.PreRelease < 0 { + return nil + } + // Normalize to the rc base so a hotfix baseVersion matches its siblings. + rcBase := base.WithRC(base.PreRelease).String() + + var matched []string + for _, tag := range tags { + v, err := version.Parse(tag) + if err != nil || v.Hotfix < 0 { + continue + } + if v.WithRC(v.PreRelease).String() == rcBase { + matched = append(matched, tag) + } + } + return matched +} diff --git a/internal/hotfix/lifecycle_test.go b/internal/hotfix/lifecycle_test.go new file mode 100644 index 00000000..cd5ff80e --- /dev/null +++ b/internal/hotfix/lifecycle_test.go @@ -0,0 +1,129 @@ +package hotfix + +import ( + "reflect" + "testing" + + "github.com/stablekernel/cascade/internal/config" +) + +func TestOrphanEnvBranches(t *testing.T) { + tests := []struct { + name string + branches []string + state map[string]*config.EnvState + want []string + }{ + { + name: "no branches yields no orphans", + branches: nil, + state: map[string]*config.EnvState{ + "test": {SHA: "abc"}, + }, + want: nil, + }, + { + name: "branch with matching divergence is not an orphan", + branches: []string{"env/test"}, + state: map[string]*config.EnvState{ + "test": {Ref: "env/test", BaseSHA: "base", Patches: []string{"p1"}}, + }, + want: nil, + }, + { + name: "branch without matching divergence is an orphan", + branches: []string{"env/test"}, + state: map[string]*config.EnvState{ + "test": {SHA: "abc"}, // not diverged + }, + want: []string{"env/test"}, + }, + { + name: "branch for an env absent from state is an orphan", + branches: []string{"env/staging"}, + state: map[string]*config.EnvState{ + "test": {Ref: "env/test"}, + }, + want: []string{"env/staging"}, + }, + { + name: "non env-prefixed branches are ignored", + branches: []string{"main", "feature/x", "env/test"}, + state: map[string]*config.EnvState{ + "test": {SHA: "abc"}, + }, + want: []string{"env/test"}, + }, + { + name: "mixed orphan and healthy branches", + branches: []string{"env/test", "env/uat"}, + state: map[string]*config.EnvState{ + "test": {Ref: "env/test", Patches: []string{"p1"}}, + "uat": {SHA: "abc"}, + }, + want: []string{"env/uat"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := OrphanEnvBranches(tt.branches, tt.state) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("OrphanEnvBranches() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHotfixTagsForBase(t *testing.T) { + tests := []struct { + name string + baseVersion string + tags []string + want []string + }{ + { + name: "rc base matches its dotted hotfix tags only", + baseVersion: "v1.4.0-rc.2", + tags: []string{ + "v1.4.0-rc.2", + "v1.4.0-rc.2.hotfix.1", + "v1.4.0-rc.2.hotfix.2", + "v1.4.0-rc.3", + "v1.4.0-rc.3.hotfix.1", // different base rc + "v1.3.0", + }, + want: []string{"v1.4.0-rc.2.hotfix.1", "v1.4.0-rc.2.hotfix.2"}, + }, + { + name: "no hotfix tags yields empty", + baseVersion: "v1.4.0-rc.2", + tags: []string{"v1.4.0-rc.2", "v1.4.0-rc.3"}, + want: nil, + }, + { + name: "hotfix base version normalizes to its rc base", + baseVersion: "v1.4.0-rc.2.hotfix.1", + tags: []string{ + "v1.4.0-rc.2.hotfix.1", + "v1.4.0-rc.2.hotfix.2", + }, + want: []string{"v1.4.0-rc.2.hotfix.1", "v1.4.0-rc.2.hotfix.2"}, + }, + { + name: "unparseable base yields empty", + baseVersion: "not-a-version", + tags: []string{"v1.4.0-rc.2.hotfix.1"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HotfixTagsForBase(tt.baseVersion, tt.tags) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("HotfixTagsForBase() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/promote/command_finalize.go b/internal/promote/command_finalize.go index 413cfed5..0b23577b 100644 --- a/internal/promote/command_finalize.go +++ b/internal/promote/command_finalize.go @@ -61,8 +61,19 @@ func runFinalize() error { targetEnv = promotionResult.Promotions[len(promotionResult.Promotions)-1].Environment } - // Create finalizer - fin, err := NewFinalizer(configPath, targetEnv) + // Create finalizer. When the workflow committed state back to trunk (a real + // promotion run, not a dry run), wire the divergence-end lifecycle cleanup so + // a promotion that rejoins a diverged env removes its integration branch, + // hotfix tags, and drafts. Without GitHub context the no-op default is kept, + // so non-diverged promotions and unit-test runs are unaffected. + var finalizeOpts []FinalizeOption + if commitPush { + if cleaner := newFinalizeCleaner(); cleaner != nil { + finalizeOpts = append(finalizeOpts, WithLifecycleCleaner(cleaner)) + } + } + + fin, err := NewFinalizer(configPath, targetEnv, finalizeOpts...) if err != nil { return err } diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index 6c6eb120..9ba1ad81 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -33,17 +33,28 @@ type Finalizer struct { promotionResult *PromotionResult actor string overrideSHA string // non-empty when an auto-committing callback advanced HEAD + + // cleaner performs the divergence-end side effects (delete env branch, hotfix + // tags, drafts) when a promotion rejoins a diverged env to trunk. The default + // is a no-op so non-diverged promotions are unaffected. + cleaner LifecycleCleaner + // pendingRejoins collects the diverged environments that rejoined trunk during + // the in-memory state update, to be cleaned up after the manifest is written. + pendingRejoins []rejoinEvent } // NewFinalizer creates a new Finalizer instance. // It loads the manifest from configPath and prepares for state updates. // The manifest must have the ci: key at the top level. -func NewFinalizer(configPath, targetEnv string) (*Finalizer, error) { - return NewFinalizerWithKey(configPath, targetEnv, config.DefaultManifestKey) +// +// Optional, additive behavior (such as the divergence-end lifecycle cleanup) is +// supplied through functional options so the required inputs stay positional. +func NewFinalizer(configPath, targetEnv string, opts ...FinalizeOption) (*Finalizer, error) { + return NewFinalizerWithKey(configPath, targetEnv, config.DefaultManifestKey, opts...) } // NewFinalizerWithKey creates a Finalizer with a custom manifest key. -func NewFinalizerWithKey(configPath, targetEnv, manifestKey string) (*Finalizer, error) { +func NewFinalizerWithKey(configPath, targetEnv, manifestKey string, opts ...FinalizeOption) (*Finalizer, error) { cicdFile, err := config.ParseManifestFile(configPath, manifestKey) if err != nil { return nil, fmt.Errorf("failed to parse manifest: %w", err) @@ -51,13 +62,18 @@ func NewFinalizerWithKey(configPath, targetEnv, manifestKey string) (*Finalizer, actor := getEnv("GITHUB_ACTOR", "github-actions[bot]") - return &Finalizer{ + f := &Finalizer{ configPath: configPath, targetEnv: targetEnv, cicdFile: cicdFile, deployResults: make(map[string]string), actor: actor, - }, nil + cleaner: noopLifecycleCleaner{}, + } + for _, opt := range opts { + opt(f) + } + return f, nil } // SetDeployResult records the result of a deploy job. @@ -99,7 +115,31 @@ func (f *Finalizer) SetHeadSHA(sha string) { // Call this only when you want to persist changes. func (f *Finalizer) Run() error { f.updateState() - return f.WriteConfig() + if err := f.WriteConfig(); err != nil { + return err + } + return f.runLifecycleCleanup() +} + +// runLifecycleCleanup performs the divergence-end side effects for every env +// that rejoined trunk during this finalization. It runs only after the manifest +// is persisted, so the source of truth is updated before any branch, tag, or +// draft is removed; a cleanup failure then leaves the manifest correct and the +// operation re-runnable. When no env rejoined (the common, non-diverged case) +// this is a no-op and the injected cleaner is never called. +func (f *Finalizer) runLifecycleCleanup() error { + for _, ev := range f.pendingRejoins { + if err := f.cleaner.DeleteEnvBranch(ev.env); err != nil { + return fmt.Errorf("rejoin cleanup for %s: %w", ev.env, err) + } + if err := f.cleaner.CleanHotfixReleases(CleanReleasesRequest{ + Environment: ev.env, + BaseVersion: ev.baseVersion, + }); err != nil { + return fmt.Errorf("rejoin cleanup for %s: %w", ev.env, err) + } + } + return nil } // updateState performs the in-memory state updates. @@ -119,6 +159,16 @@ func (f *Finalizer) updateState() { f.cicdFile.State[promo.Environment] = &config.EnvState{} } state := f.cicdFile.State[promo.Environment] + // Capture whether this env was diverged BEFORE overwriting its state. + // A promotion into a diverged env that reaches finalize has already + // passed the preflight patch-containment gate (the incoming trunk SHA + // contains every recorded patch), so the env is rejoining trunk: its + // divergence fields must be cleared and its integration branch, tags, + // and drafts cleaned up. The base version to clean is the version the + // env held while diverged, captured here before it is overwritten. + wasDiverged := state.IsDiverged() + priorVersion := state.Version + // When an auto-committing callback ran, overrideSHA holds the // post-callback HEAD; use it so the recorded state points at the // commit that was actually built/deployed rather than the triggering SHA. @@ -131,6 +181,19 @@ func (f *Finalizer) updateState() { state.CommittedAt = timestamp state.CommittedBy = f.actor + // Rejoin: clear divergence fields and schedule cleanup. This is gated + // on the env having been diverged, so a normal promotion into a + // non-diverged env touches none of the lifecycle logic. + if wasDiverged { + state.Ref = "" + state.BaseSHA = "" + state.Patches = nil + f.pendingRejoins = append(f.pendingRejoins, rejoinEvent{ + env: promo.Environment, + baseVersion: priorVersion, + }) + } + // Initialize deploys map if needed if state.Deploys == nil { state.Deploys = make(map[string]*config.DeployState) diff --git a/internal/promote/rejoin.go b/internal/promote/rejoin.go new file mode 100644 index 00000000..34c3086d --- /dev/null +++ b/internal/promote/rejoin.go @@ -0,0 +1,145 @@ +package promote + +import ( + "fmt" + "os" + + "github.com/stablekernel/cascade/internal/git" + "github.com/stablekernel/cascade/internal/hotfix" + "github.com/stablekernel/cascade/internal/release" +) + +// CleanReleasesRequest describes the hotfix release objects to remove when an +// environment rejoins trunk. BaseVersion is the version the environment held +// while diverged; its rc base identifies the hotfix tags (vX.Y.Z-rc.N.hotfix.M) +// and drafts that the RC-shaped cleanup deliberately cannot see. +type CleanReleasesRequest struct { + Environment string + BaseVersion string +} + +// LifecycleCleaner performs the side effects of ending a divergence: deleting +// the per-environment integration branch and removing the hotfix tags and +// release objects minted for that base. It is a small interface with a no-op +// default so a normal promotion into a non-diverged environment is never forced +// to provide one; the production implementation is wired only when finalize runs +// in a repository with GitHub context. +type LifecycleCleaner interface { + // DeleteEnvBranch deletes the env/ integration branch. + DeleteEnvBranch(env string) error + // CleanHotfixReleases deletes the hotfix tags and release drafts for the + // rejoining environment's prior base version. + CleanHotfixReleases(req CleanReleasesRequest) error +} + +// noopLifecycleCleaner is the default cleaner. It performs no side effects, so a +// Finalizer constructed without WithLifecycleCleaner behaves exactly as before +// for non-diverged promotions. +type noopLifecycleCleaner struct{} + +func (noopLifecycleCleaner) DeleteEnvBranch(string) error { return nil } +func (noopLifecycleCleaner) CleanHotfixReleases(CleanReleasesRequest) error { return nil } + +// FinalizeOption customizes optional, additive Finalizer behavior. Required +// inputs stay positional on the constructor; cross-cutting concerns such as the +// divergence-end cleanup are threaded through options so existing callers and +// signatures are unaffected. +type FinalizeOption func(*Finalizer) + +// WithLifecycleCleaner injects the divergence-end cleanup performed when a +// promotion rejoins a diverged environment to trunk. The default is a no-op, so +// promotions into non-diverged environments incur no cleanup behavior. +func WithLifecycleCleaner(c LifecycleCleaner) FinalizeOption { + return func(f *Finalizer) { + if c != nil { + f.cleaner = c + } + } +} + +// rejoinEvent records that a diverged environment rejoined trunk during +// finalization, carrying the data the cleaner needs to remove its branch, tags, +// and drafts. +type rejoinEvent struct { + env string + baseVersion string +} + +// gitReleaseCleaner is the production LifecycleCleaner. It deletes the remote +// integration branch with git and removes the hotfix tags and drafts through the +// release API. It is constructed only when finalize has the GitHub context +// (repository and token) needed to act; without it, the no-op default is used. +type gitReleaseCleaner struct { + remote string + releaseMgr *release.Manager + listTags func() ([]string, error) + deleteTag func(remote, name string) error +} + +// newGitReleaseCleaner builds a production cleaner. remote is the git remote that +// hosts the env/* branches (typically "origin"); mgr performs release deletes. +func newGitReleaseCleaner(remote string, mgr *release.Manager) *gitReleaseCleaner { + return &gitReleaseCleaner{ + remote: remote, + releaseMgr: mgr, + listTags: git.ListTags, + deleteTag: git.DeleteRemoteTag, + } +} + +// newFinalizeCleaner builds the production LifecycleCleaner from the workflow +// environment. It returns nil when GITHUB_REPOSITORY is unset (no GitHub context, +// for example an act/gitea run without the API), in which case the finalizer +// keeps its no-op default and clears the manifest fields without touching tags or +// drafts. The integration branch is still git-deletable in that environment, but +// without the repository the release-object cleanup cannot run, so the cleaner is +// only wired when both are possible. +func newFinalizeCleaner() LifecycleCleaner { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return nil + } + token := os.Getenv("RELEASE_TOKEN") + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + return newGitReleaseCleaner("origin", release.NewManager(repo, token)) +} + +// DeleteEnvBranch deletes the env/ branch on the configured remote. +func (c *gitReleaseCleaner) DeleteEnvBranch(env string) error { + branch := hotfix.EnvBranchPrefix + env + if err := git.DeleteRemoteBranch(c.remote, branch); err != nil { + return fmt.Errorf("deleting integration branch %s: %w", branch, err) + } + return nil +} + +// CleanHotfixReleases deletes the hotfix tags for the prior base version and the +// matching draft release objects. Tag and draft deletion is best-effort per +// item so one stale object does not block the others; the first hard error is +// returned. +func (c *gitReleaseCleaner) CleanHotfixReleases(req CleanReleasesRequest) error { + tags, err := c.listTags() + if err != nil { + return fmt.Errorf("listing tags for hotfix cleanup: %w", err) + } + hotfixTags := hotfix.HotfixTagsForBase(req.BaseVersion, tags) + + var firstErr error + for _, tag := range hotfixTags { + // Remove the draft release object for the hotfix tag, then the tag. + if c.releaseMgr != nil { + if _, err := c.releaseMgr.Manage(release.Options{ + Action: release.ActionDelete, + Tag: tag, + }); err != nil && firstErr == nil { + firstErr = fmt.Errorf("deleting hotfix release %s: %w", tag, err) + } + } + if err := c.deleteTag(c.remote, tag); err != nil && firstErr == nil { + firstErr = fmt.Errorf("deleting hotfix tag %s: %w", tag, err) + } + } + return firstErr +} diff --git a/internal/promote/rejoin_integration_test.go b/internal/promote/rejoin_integration_test.go new file mode 100644 index 00000000..b812b6e2 --- /dev/null +++ b/internal/promote/rejoin_integration_test.go @@ -0,0 +1,173 @@ +package promote + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/git" + "github.com/stablekernel/cascade/internal/release" + "github.com/stretchr/testify/require" +) + +// runGit runs a git command in dir and fails the test on error. +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func gitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)) +} + +// TestRejoin_Integration_FullLifecycle exercises the divergence-end lifecycle +// end to end against a real git repository and a release-stub server: an env is +// diverged on a real env/ branch with a hotfix tag and draft, a normal +// promotion of a containing trunk SHA finalizes, and the env rejoins trunk with +// cleared fields, the integration branch deleted, the hotfix tag and draft +// removed, and another env's divergence left intact. The act/gitea e2e for this +// flow is owned by the e2e harness unit; this is the committed scratch-repo plus +// release-stub coverage. +func TestRejoin_Integration_FullLifecycle(t *testing.T) { + // Bare origin plus a working clone, so env/* branches and tags are real + // remote refs the cleanup can delete. + originDir := t.TempDir() + runGit(t, originDir, "init", "--bare", "-b", "main") + + workDir := t.TempDir() + runGit(t, workDir, "clone", originDir, ".") + runGit(t, workDir, "config", "user.email", "test@example.com") + runGit(t, workDir, "config", "user.name", "Test User") + runGit(t, workDir, "config", "commit.gpgsign", "false") + + // base commit, then the fix commit on trunk. + require.NoError(t, os.WriteFile(filepath.Join(workDir, "a.txt"), []byte("one"), 0644)) + runGit(t, workDir, "add", "a.txt") + runGit(t, workDir, "commit", "-m", "first") + baseSHA := gitOut(t, workDir, "rev-parse", "HEAD") + + require.NoError(t, os.WriteFile(filepath.Join(workDir, "fix.txt"), []byte("patched"), 0644)) + runGit(t, workDir, "add", "fix.txt") + runGit(t, workDir, "commit", "-m", "fix on trunk") + trunkHead := gitOut(t, workDir, "rev-parse", "HEAD") + runGit(t, workDir, "push", "origin", "main") + + // Create the env/test integration branch at base and a hotfix merge commit on + // it, then push it as a real remote branch the cleanup will delete. + runGit(t, workDir, "checkout", "-b", "env/test", baseSHA) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "hotfix.txt"), []byte("hf"), 0644)) + runGit(t, workDir, "add", "hotfix.txt") + runGit(t, workDir, "commit", "-m", "hotfix merge") + mergeSHA := gitOut(t, workDir, "rev-parse", "HEAD") + runGit(t, workDir, "push", "origin", "env/test") + + // Hotfix tag for the diverged base version, pushed to origin. + runGit(t, workDir, "tag", "v1.4.0-rc.2.hotfix.1", mergeSHA) + runGit(t, workDir, "push", "origin", "v1.4.0-rc.2.hotfix.1") + runGit(t, workDir, "checkout", "main") + runGit(t, workDir, "fetch", "origin", "--prune", "--tags") + + // Manifest: test diverged on env/test, uat diverged independently. + configPath := filepath.Join(workDir, "manifest.yaml") + manifest := `ci: + config: + environments: [dev, test, uat, prod] + state: + dev: + sha: ` + trunkHead + ` + version: v1.4.0-rc.3 + test: + sha: ` + mergeSHA + ` + version: v1.4.0-rc.2.hotfix.1 + ref: env/test + base_sha: ` + baseSHA + ` + patches: [` + trunkHead + `] + uat: + sha: uatmerge + version: v1.3.0-rc.5.hotfix.1 + ref: env/uat + base_sha: uatbase + patches: [patchY] +` + require.NoError(t, os.WriteFile(configPath, []byte(manifest), 0644)) + + // Release-stub server: report one draft for the hotfix tag, accept its delete. + var deletedReleaseIDs []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/releases/tags/v1.4.0-rc.2.hotfix.1"): + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(release.GitHubRelease{ID: 42, TagName: "v1.4.0-rc.2.hotfix.1", Draft: true}) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/releases/"): + deletedReleaseIDs = append(deletedReleaseIDs, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{}) + } + })) + defer server.Close() + + mgr := release.NewManagerWithURL("owner/repo", "token", server.URL) + cleaner := newGitReleaseCleaner("origin", mgr) + + // Run the real Finalizer from inside the work tree so git operations resolve. + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(workDir)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + fin, err := NewFinalizer("manifest.yaml", "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: trunkHead, + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, fin.Run()) + + // Manifest: test rejoined trunk, fields cleared; uat divergence preserved. + cicd, err := config.ParseManifestFile("manifest.yaml", config.DefaultManifestKey) + require.NoError(t, err) + st := cicd.State["test"] + require.Equal(t, trunkHead, st.SHA) + require.False(t, st.IsDiverged(), "test must have rejoined trunk") + require.Empty(t, st.Ref) + require.Empty(t, st.Patches) + + uat := cicd.State["uat"] + require.True(t, uat.IsDiverged(), "uat divergence preserved") + require.Equal(t, "env/uat", uat.Ref) + + // The env/test branch is deleted on origin. + runGit(t, workDir, "fetch", "origin", "--prune") + exists, err := git.BranchExists("origin", "env/test") + require.NoError(t, err) + require.False(t, exists, "env/test integration branch must be deleted") + + // The hotfix tag is gone from origin, and the draft release was deleted. + remoteTags := gitOut(t, workDir, "ls-remote", "--tags", "origin") + require.NotContains(t, remoteTags, "v1.4.0-rc.2.hotfix.1", "hotfix tag must be deleted on origin") + require.NotEmpty(t, deletedReleaseIDs, "hotfix draft release must be deleted") +} diff --git a/internal/promote/rejoin_test.go b/internal/promote/rejoin_test.go new file mode 100644 index 00000000..a2c2efcc --- /dev/null +++ b/internal/promote/rejoin_test.go @@ -0,0 +1,214 @@ +package promote + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// recordingCleaner is a test LifecycleCleaner that records the rejoin cleanups it +// is asked to perform, so tests can assert which side effects fired (and that +// non-diverged promotions fire none). +type recordingCleaner struct { + deletedBranches []string + cleanedReleases []CleanReleasesRequest +} + +func (c *recordingCleaner) DeleteEnvBranch(env string) error { + c.deletedBranches = append(c.deletedBranches, env) + return nil +} + +func (c *recordingCleaner) CleanHotfixReleases(req CleanReleasesRequest) error { + c.cleanedReleases = append(c.cleanedReleases, req) + return nil +} + +// divergedManifest writes a manifest where "test" is diverged on env/test with a +// single patch on top of a trunk base, and "uat" is also diverged independently. +func divergedManifest(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + initialConfig := `ci: + config: + environments: [dev, test, uat, prod] + state: + dev: + sha: trunkhead + version: v1.4.0-rc.3 + test: + sha: mergesha + version: v1.4.0-rc.2.hotfix.1 + ref: env/test + base_sha: basesha + patches: [patchX] + uat: + sha: uatmerge + version: v1.3.0-rc.5.hotfix.1 + ref: env/uat + base_sha: uatbase + patches: [patchY] +` + require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644)) + return configPath +} + +func TestRejoin_ClearsDivergenceFields(t *testing.T) { + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + + // Normal trunk promotion into the diverged "test" env: incoming SHA contains + // the recorded patch (containment gate already passed in preflight). + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, fin.Run()) + + cicd, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) + require.NoError(t, err) + + st := cicd.State["test"] + require.NotNil(t, st) + require.Equal(t, "trunkhead", st.SHA) + require.Equal(t, "v1.4.0-rc.3", st.Version) + require.Empty(t, st.Ref, "ref must be cleared on rejoin") + require.Empty(t, st.BaseSHA, "base_sha must be cleared on rejoin") + require.Empty(t, st.Patches, "patches must be cleared on rejoin") + require.False(t, st.IsDiverged(), "env must no longer be diverged") +} + +func TestRejoin_DeletesEnvBranch(t *testing.T) { + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, fin.Run()) + + require.Equal(t, []string{"test"}, cleaner.deletedBranches, + "the rejoined env's integration branch must be deleted exactly once") +} + +func TestRejoin_CleansHotfixTagsAndDrafts(t *testing.T) { + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, fin.Run()) + + require.Len(t, cleaner.cleanedReleases, 1) + req := cleaner.cleanedReleases[0] + require.Equal(t, "test", req.Environment) + // The base version cleaned is the version the env held while diverged. + require.Equal(t, "v1.4.0-rc.2.hotfix.1", req.BaseVersion) +} + +func TestRejoin_PreservesOtherEnvsDivergence(t *testing.T) { + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + + // Only "test" is being promoted into; "uat" stays diverged and untouched. + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, fin.Run()) + + cicd, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) + require.NoError(t, err) + + uat := cicd.State["uat"] + require.NotNil(t, uat) + require.True(t, uat.IsDiverged(), "uat divergence must be preserved") + require.Equal(t, "env/uat", uat.Ref) + require.Equal(t, "uatbase", uat.BaseSHA) + require.Equal(t, []string{"patchY"}, uat.Patches) + + // Cleanup must only have touched the rejoined env. + require.Equal(t, []string{"test"}, cleaner.deletedBranches) + require.Len(t, cleaner.cleanedReleases, 1) + require.Equal(t, "test", cleaner.cleanedReleases[0].Environment) +} + +func TestNormalPromotion_NonDiverged_TouchesNoLifecycle(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + initialConfig := `ci: + config: + environments: [dev, test, uat, prod] + state: + dev: + sha: abc123 + version: v1.0.0-rc.1 + test: + sha: oldsha + version: v1.0.0-rc.0 +` + require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644)) + + cleaner := &recordingCleaner{} + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "abc123", + Version: "v1.0.0-rc.1", + }}, + }) + + require.NoError(t, fin.Run()) + + require.Empty(t, cleaner.deletedBranches, "non-diverged promotion must delete no branch") + require.Empty(t, cleaner.cleanedReleases, "non-diverged promotion must clean no releases") + + cicd, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) + require.NoError(t, err) + st := cicd.State["test"] + require.Equal(t, "abc123", st.SHA) + require.False(t, st.IsDiverged()) +} diff --git a/internal/status/command.go b/internal/status/command.go index e520f6a6..8004655a 100644 --- a/internal/status/command.go +++ b/internal/status/command.go @@ -41,6 +41,7 @@ When no subcommand is given, all environments are summarised together with lates cmd.AddCommand(newEnvCommand(&configPath, &manifestKey, &jsonOutput)) cmd.AddCommand(newBuildCommand(&configPath, &manifestKey, &jsonOutput)) cmd.AddCommand(newDeployCommand(&configPath, &manifestKey, &jsonOutput)) + cmd.AddCommand(newConsistencyCommand(&configPath, &manifestKey, &jsonOutput)) return cmd } diff --git a/internal/status/consistency.go b/internal/status/consistency.go new file mode 100644 index 00000000..6a7eccbd --- /dev/null +++ b/internal/status/consistency.go @@ -0,0 +1,79 @@ +package status + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/git" + "github.com/stablekernel/cascade/internal/hotfix" +) + +// branchLister returns the branch names to check for orphans. The default lists +// the origin remote's branches; tests inject a fixed set so the consistency +// check is exercised without a real repository. +type branchLister func() ([]string, error) + +// defaultBranchLister lists the origin remote's branches. +func defaultBranchLister() ([]string, error) { + return git.ListRemoteBranches("origin") +} + +// newConsistencyCommand creates the 'status consistency' subcommand. It reports +// env/* integration branches that have no matching divergence in the manifest: +// a hotfix leaves an env/ branch only while state[] stays diverged, +// so a branch with no diverged env behind it is an orphan from an interrupted +// hotfix or manual meddling and is surfaced here for cleanup. +func newConsistencyCommand(configPath, key *string, jsonOutput *bool) *cobra.Command { + cmd := &cobra.Command{ + Use: "consistency", + Short: "Flag env/* branches with no matching manifest divergence", + Long: `Check for orphan integration branches. + +A hotfix creates an env/ branch that exists only while the environment is +diverged. When the environment rejoins trunk the branch is deleted. This command +flags any env/ branch that has no matching divergence in the manifest, which +indicates an interrupted hotfix or manual branch creation that should be cleaned up.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runConsistency(*configPath, *key, *jsonOutput, defaultBranchLister) + }, + } + return cmd +} + +// consistencyOutput is the JSON shape for the consistency command. +type consistencyOutput struct { + OrphanEnvBranches []string `json:"orphan_env_branches"` +} + +// runConsistency loads the manifest, lists branches via lister, and reports the +// orphan env/* branches. It is the testable core of the consistency subcommand; +// the branch lister is injected so the check runs without a repository in tests. +func runConsistency(configPath, key string, jsonOutput bool, lister branchLister) error { + file, err := loadManifest(configPath, key) + if err != nil { + return err + } + + branches, err := lister() + if err != nil { + return fmt.Errorf("listing branches: %w", err) + } + + orphans := hotfix.OrphanEnvBranches(branches, file.State) + + if jsonOutput { + return printJSON(consistencyOutput{OrphanEnvBranches: orphans}) + } + + if len(orphans) == 0 { + fmt.Println("no orphan env/* branches found") + return nil + } + + fmt.Println("orphan env/* branches (no matching manifest divergence):") + for _, b := range orphans { + fmt.Printf(" %s\n", b) + } + return nil +} diff --git a/internal/status/consistency_test.go b/internal/status/consistency_test.go new file mode 100644 index 00000000..8be76144 --- /dev/null +++ b/internal/status/consistency_test.go @@ -0,0 +1,81 @@ +package status + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeConsistencyManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + content := `ci: + config: + environments: [dev, test, uat, prod] + state: + dev: + sha: trunkhead + test: + sha: mergesha + ref: env/test + base_sha: basesha + patches: [patchX] +` + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + return path +} + +func TestConsistency_OrphanEnvBranchFlagged(t *testing.T) { + path := writeConsistencyManifest(t) + + // env/test matches the diverged "test" env (healthy); env/staging has no + // matching divergence (orphan). + brancher := func() ([]string, error) { + return []string{"main", "env/test", "env/staging"}, nil + } + + out := captureOutput(t, func() { + err := runConsistency(path, "ci", false, brancher) + require.NoError(t, err) + }) + + assert.Contains(t, out, "env/staging", "orphan branch must be reported") + assert.NotContains(t, out, "env/test", "healthy diverged branch must not be reported") +} + +func TestConsistency_NoOrphans(t *testing.T) { + path := writeConsistencyManifest(t) + + brancher := func() ([]string, error) { + return []string{"main", "env/test"}, nil + } + + out := captureOutput(t, func() { + err := runConsistency(path, "ci", false, brancher) + require.NoError(t, err) + }) + + assert.Contains(t, strings.ToLower(out), "no orphan") +} + +func TestConsistency_JSON(t *testing.T) { + path := writeConsistencyManifest(t) + + brancher := func() ([]string, error) { + return []string{"env/test", "env/staging"}, nil + } + + out := captureOutput(t, func() { + err := runConsistency(path, "ci", true, brancher) + require.NoError(t, err) + }) + + assert.Contains(t, out, `"orphan_env_branches"`) + assert.Contains(t, out, "env/staging") + assert.NotContains(t, out, "env/test") +}