From 5fd475abce7bb682b262d7b67a7bbd923571fe85 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 25 Jun 2026 13:30:50 -0400 Subject: [PATCH 1/2] feat(simulate): add rollback, release, and hotfix what-if actions Signed-off-by: Joshua Temple --- internal/hotfix/finalize.go | 12 ++ internal/simulate/command.go | 190 +++++++++++++++--- internal/simulate/command_test.go | 50 +++++ internal/simulate/effect_test.go | 67 ++++++ internal/simulate/engine_test.go | 49 +++++ internal/simulate/hotfix_action.go | 179 +++++++++++++++++ internal/simulate/hotfix_action_test.go | 120 +++++++++++ internal/simulate/promote_action.go | 41 ++++ internal/simulate/release_action.go | 61 ++++++ internal/simulate/release_action_test.go | 67 ++++++ internal/simulate/rollback_action.go | 119 +++++++++++ internal/simulate/rollback_action_test.go | 150 ++++++++++++++ .../simulate/testdata/promote_human.golden | 3 +- .../simulate/testdata/promote_json.golden | 6 + 14 files changed, 1081 insertions(+), 33 deletions(-) create mode 100644 internal/simulate/hotfix_action.go create mode 100644 internal/simulate/hotfix_action_test.go create mode 100644 internal/simulate/release_action.go create mode 100644 internal/simulate/release_action_test.go create mode 100644 internal/simulate/rollback_action.go create mode 100644 internal/simulate/rollback_action_test.go diff --git a/internal/hotfix/finalize.go b/internal/hotfix/finalize.go index b2133cf9..b052553e 100644 --- a/internal/hotfix/finalize.go +++ b/internal/hotfix/finalize.go @@ -281,6 +281,18 @@ func WithTrunkStateReader(r trunkStateReader) FinalizeOption { } } +// WithTipReader injects the reader Finalize uses to cross-check the merge SHA +// against the resolution branch tip. The default reads the local env-branch tip +// via git. The what-if simulator injects a record-only reader so finalize can +// run without a git checkout. +func WithTipReader(r gitTipReader) FinalizeOption { + return func(f *Finalizer) { + if r != nil { + f.tipReader = r + } + } +} + // NewFinalizer constructs a Finalizer over the manifest at opts.ConfigPath. func NewFinalizer(opts FinalizerOptions, options ...FinalizeOption) (*Finalizer, error) { key := opts.ManifestKey diff --git a/internal/simulate/command.go b/internal/simulate/command.go index e0154d3d..5f616ba7 100644 --- a/internal/simulate/command.go +++ b/internal/simulate/command.go @@ -3,6 +3,7 @@ package simulate import ( "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -10,18 +11,14 @@ import ( "github.com/stablekernel/cascade/internal/promote" ) -// flags shared across the simulate subcommands. -var ( - flagConfig string - flagJSON bool - flagActor string -) - -// promote subcommand flags. -var ( - flagMode string - flagTarget string -) +// commonFlags holds the flags shared by every simulate subcommand. They are +// bound per NewCommand invocation rather than as package globals so concurrent +// command construction (for example parallel tests) never races on shared state. +type commonFlags struct { + config string + json bool + actor string +} const simulateLong = `Run a hypothetical action against a clone of your manifest and print what would happen, without changing anything. @@ -45,62 +42,191 @@ no containers. No on-disk state is changed.` // NewCommand builds the simulate parent command and its subcommands. func NewCommand() *cobra.Command { + cf := &commonFlags{} + cmd := &cobra.Command{ Use: "simulate", Short: "Preview a hypothetical action without changing anything", Long: simulateLong, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if flagConfig == "" { - flagConfig = config.FindConfigFile("") + if cf.config == "" { + cf.config = config.FindConfigFile("") } return nil }, } - cmd.PersistentFlags().StringVar(&flagConfig, "config", "", "Path to manifest file (default: .github/manifest.yaml)") - cmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Output result as JSON") - cmd.PersistentFlags().StringVar(&flagActor, "actor", "", "Actor performing the hypothetical action") + cmd.PersistentFlags().StringVar(&cf.config, "config", "", "Path to manifest file (default: .github/manifest.yaml)") + cmd.PersistentFlags().BoolVar(&cf.json, "json", false, "Output result as JSON") + cmd.PersistentFlags().StringVar(&cf.actor, "actor", "", "Actor performing the hypothetical action") - cmd.AddCommand(newPromoteCommand()) + cmd.AddCommand(newPromoteCommand(cf)) + cmd.AddCommand(newRollbackCommand(cf)) + cmd.AddCommand(newReleaseCommand(cf)) + cmd.AddCommand(newHotfixCommand(cf)) return cmd } +// runSimulation builds the engine and renders the action result, shared by every +// subcommand so output formatting stays identical across actions. +func runSimulation(cf *commonFlags, a Action) error { + engine, err := NewEngine(cf.config, WithActor(cf.actor)) + if err != nil { + return err + } + + result, err := engine.Simulate(a) + if err != nil { + return err + } + + if cf.json { + return result.RenderJSON(os.Stdout) + } + return result.RenderHuman(os.Stdout) +} + // newPromoteCommand builds the `simulate promote` subcommand. -func newPromoteCommand() *cobra.Command { +func newPromoteCommand(cf *commonFlags) *cobra.Command { + var ( + mode string + target string + ) + cmd := &cobra.Command{ Use: "promote", Short: "Simulate a promotion", Long: promoteLong, RunE: func(cmd *cobra.Command, args []string) error { - mode, err := parseMode(flagMode) + m, err := parseMode(mode) if err != nil { return err } + return runSimulation(cf, NewPromoteAction(m, target)) + }, + } - engine, err := NewEngine(flagConfig, WithActor(flagActor)) - if err != nil { - return err - } + cmd.Flags().StringVar(&mode, "mode", "default", "Promotion mode: default or cascade") + cmd.Flags().StringVar(&target, "target", "", "Cascade target (for example dev-to-prod)") - result, err := engine.Simulate(NewPromoteAction(mode, flagTarget)) - if err != nil { - return err + return cmd +} + +const rollbackLong = `Simulate a rollback against a clone of your manifest. + +This replays the real rollback target resolution and state revert in record-only +mode and prints the resulting state diff plus an ordered effect sequence. Target +resolution is pinned to the in-state deploy-history ring. It validates the +orchestration transitions, not your deploy scripts. +It touches no GitHub and no containers. No on-disk state is changed.` + +const releaseLong = `Simulate a release crossing against a clone of your manifest. + +This replays the real promotion state-machine across the release boundary in +record-only mode and prints the state diff plus an ordered effect sequence, +including the prerelease or publish marker the orchestration would emit. It +validates the orchestration transitions and the release decision, not the GitHub +release API calls, and touches no GitHub and no containers. No on-disk state is +changed.` + +const hotfixLong = `Simulate a hotfix against a clone of your manifest. + +This replays the real hotfix finalize state-machine in record-only mode: it +allocates the next hotfix version, snapshots the prior state, and writes the +divergence fields, then prints the resulting state diff plus an ordered effect +sequence. It validates the orchestration transitions, not your deploy scripts, +and touches no GitHub and no containers. No on-disk state is changed.` + +// newRollbackCommand builds the `simulate rollback` subcommand. +func newRollbackCommand(cf *commonFlags) *cobra.Command { + var ( + env string + to string + deployable string + ) + + cmd := &cobra.Command{ + Use: "rollback", + Short: "Simulate a rollback", + Long: rollbackLong, + RunE: func(cmd *cobra.Command, args []string) error { + if env == "" { + return fmt.Errorf("rollback requires --env") } + return runSimulation(cf, NewRollbackAction(env, to, deployable)) + }, + } + + cmd.Flags().StringVar(&env, "env", "", "Environment to roll back") + cmd.Flags().StringVar(&to, "to", "", "Target SHA or version (default: previous distinct state)") + cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable") + + return cmd +} + +// newReleaseCommand builds the `simulate release` subcommand. +func newReleaseCommand(cf *commonFlags) *cobra.Command { + return &cobra.Command{ + Use: "release", + Short: "Simulate a release crossing", + Long: releaseLong, + RunE: func(cmd *cobra.Command, args []string) error { + return runSimulation(cf, NewReleaseAction()) + }, + } +} - if flagJSON { - return result.RenderJSON(os.Stdout) +// newHotfixCommand builds the `simulate hotfix` subcommand. +func newHotfixCommand(cf *commonFlags) *cobra.Command { + var ( + env string + fix string + mergeSHA string + ) + + cmd := &cobra.Command{ + Use: "hotfix", + Short: "Simulate a hotfix", + Long: hotfixLong, + RunE: func(cmd *cobra.Command, args []string) error { + if env == "" { + return fmt.Errorf("hotfix requires --env") } - return result.RenderHuman(os.Stdout) + fixSHAs, err := parseCommaList(fix) + if err != nil { + return fmt.Errorf("invalid --fix: %w", err) + } + return runSimulation(cf, NewHotfixAction(env, fixSHAs, mergeSHA)) }, } - cmd.Flags().StringVar(&flagMode, "mode", "default", "Promotion mode: default or cascade") - cmd.Flags().StringVar(&flagTarget, "target", "", "Cascade target (for example dev-to-prod)") + cmd.Flags().StringVar(&env, "env", "", "Environment to hotfix") + cmd.Flags().StringVar(&fix, "fix", "", "Comma-separated trunk commit SHAs the hotfix carries") + cmd.Flags().StringVar(&mergeSHA, "merge-sha", "", "Resolution-branch tip (default: first fix commit)") return cmd } +// parseCommaList splits a comma-separated input into a trimmed, non-empty slice. +// It rejects empty input and any blank entry so a hotfix simulation always names +// at least one concrete commit. +func parseCommaList(input string) ([]string, error) { + if strings.TrimSpace(input) == "" { + return nil, fmt.Errorf("no commit refs given") + } + parts := strings.Split(input, ",") + out := make([]string, 0, len(parts)) + for _, raw := range parts { + ref := strings.TrimSpace(raw) + if ref == "" { + return nil, fmt.Errorf("empty commit ref in %q", input) + } + out = append(out, ref) + } + return out, nil +} + // parseMode maps the flag string to a promote.PromotionMode. func parseMode(s string) (promote.PromotionMode, error) { switch s { diff --git a/internal/simulate/command_test.go b/internal/simulate/command_test.go index 97fc0353..576d7204 100644 --- a/internal/simulate/command_test.go +++ b/internal/simulate/command_test.go @@ -48,3 +48,53 @@ func TestSimulatePromote_InvalidMode(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "invalid mode") } + +func TestSimulateSubcommands_Registered(t *testing.T) { + t.Parallel() + + out := helpText(t, "--help") + for _, sub := range []string{"promote", "rollback", "release", "hotfix"} { + assert.Contains(t, out, sub) + } +} + +func TestSimulateRollbackHelp_MentionsScopeAndIsolation(t *testing.T) { + t.Parallel() + + out := strings.ToLower(helpText(t, "rollback", "--help")) + assert.Contains(t, out, "orchestration") + assert.Contains(t, out, "no github") + assert.Contains(t, out, "no containers") +} + +func TestSimulateReleaseHelp_MentionsScopeAndIsolation(t *testing.T) { + t.Parallel() + + out := strings.ToLower(helpText(t, "release", "--help")) + assert.Contains(t, out, "orchestration") + assert.Contains(t, out, "no github") + assert.Contains(t, out, "no containers") +} + +func TestSimulateHotfixHelp_MentionsScopeAndIsolation(t *testing.T) { + t.Parallel() + + out := strings.ToLower(helpText(t, "hotfix", "--help")) + assert.Contains(t, out, "orchestration") + assert.Contains(t, out, "no github") + assert.Contains(t, out, "no containers") +} + +func TestParseCommaList(t *testing.T) { + t.Parallel() + + got, err := parseCommaList(" a , b ,c") + require.NoError(t, err) + assert.Equal(t, []string{"a", "b", "c"}, got) + + _, err = parseCommaList("") + require.Error(t, err) + + _, err = parseCommaList("a,,b") + require.Error(t, err) +} diff --git a/internal/simulate/effect_test.go b/internal/simulate/effect_test.go index edced698..f72b96cb 100644 --- a/internal/simulate/effect_test.go +++ b/internal/simulate/effect_test.go @@ -50,6 +50,73 @@ func TestEffectsFromResult_ReleaseMarkerAdvanceIsWriteStateNotDeploy(t *testing. assert.Equal(t, "prod", effects[0].Target) } +func TestReleaseMarkerEffect_PrereleaseAndPublish(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + action string + wantAction string + }{ + {name: "prerelease", action: "prerelease", wantAction: "release prerelease"}, + {name: "publish", action: "publish", wantAction: "release publish"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := &promote.PromotionResult{ + Success: true, + Mode: promote.ModeDefault, + ReleaseAction: tc.action, + ReleaseData: &promote.ReleaseData{SHA: "a1b2c3d4e5f6", RCVersion: "v1.0.0-rc.3", SemVersion: "v1.0.0"}, + } + + marker, ok := releaseMarkerEffect(result) + require.True(t, ok) + assert.Equal(t, tc.wantAction, marker.Action) + assert.Equal(t, "v1.0.0", marker.Target, "the target is the semver being marked") + assert.Equal(t, DispositionRun, marker.Disposition) + assert.Contains(t, marker.Detail, "v1.0.0-rc.3") + }) + } +} + +func TestReleaseMarkerEffect_NoMarkerWhenEmpty(t *testing.T) { + t.Parallel() + + _, ok := releaseMarkerEffect(&promote.PromotionResult{Success: true}) + assert.False(t, ok) + + _, ok = releaseMarkerEffect(nil) + assert.False(t, ok) +} + +func TestEffectsFromResult_AppendsReleaseMarkerAfterPromotions(t *testing.T) { + t.Parallel() + + result := &promote.PromotionResult{ + Success: true, + Mode: promote.ModeDefault, + Promotions: []promote.EnvPromotion{ + {Environment: "uat", SourceEnv: "dev", SHA: "a1b2c3d", Version: "v1.0.0-rc.0", NeedsDeploy: true}, + }, + ReleaseAction: "prerelease", + ReleaseData: &promote.ReleaseData{SHA: "a1b2c3d", RCVersion: "v1.0.0-rc.0", SemVersion: "v1.0.0"}, + SkippedEnvs: []string{"prod"}, + } + + effects := effectsFromResult(result) + require.Len(t, effects, 4) + assert.Equal(t, "deploy", effects[0].Action) + assert.Equal(t, "write state", effects[1].Action) + assert.Equal(t, "release prerelease", effects[2].Action) + assert.Equal(t, DispositionSkip, effects[3].Disposition) + assert.Equal(t, "prod", effects[3].Target) +} + func TestEffectsFromResult_SkippedEnvs(t *testing.T) { t.Parallel() diff --git a/internal/simulate/engine_test.go b/internal/simulate/engine_test.go index c9cbb05c..aa4f4798 100644 --- a/internal/simulate/engine_test.go +++ b/internal/simulate/engine_test.go @@ -97,6 +97,55 @@ func TestEngine_Simulate_LeavesOriginalUntouched(t *testing.T) { assert.Equal(t, before, after, "the original manifest bytes must be unchanged") } +func TestEngine_Simulate_PromoteCascade(t *testing.T) { + t.Parallel() + + // dev populated, uat and prod empty: a cascade to prod carries dev's state + // through every intermediate hop atomically. + path := writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "uat", "prod"}}, + State: map[string]*config.EnvState{ + "dev": {SHA: "a1b2c3d4e5f6", Version: "v1.2.0-rc.1"}, + "uat": {}, + "prod": {}, + }, + }) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewPromoteAction(promote.ModeCascade, "dev-to-prod")) + require.NoError(t, err) + + assert.True(t, result.Diff.Changed(), "cascade advances the intermediate envs") + uat, ok := result.Diff.Env("uat") + require.True(t, ok) + assert.Equal(t, "a1b2c3d4e5f6", uat.SHA.To) + require.NotEmpty(t, result.Effects) +} + +func TestEngine_Simulate_NoEnvironmentManifest(t *testing.T) { + t.Parallel() + + // Library/CLI project: no environments declared. The implicit prerelease to + // release chain publishes, so the release marker is the headline effect. + path := writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{TrunkBranch: "main"}, + State: map[string]*config.EnvState{ + "prerelease": {SHA: "a1b2c3d4e5f6", Version: "v1.0.0-rc.0"}, + }, + }) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + _, ok := findEffect(result.Effects, "release publish") + assert.True(t, ok, "a no-environment publish surfaces the publish marker") +} + func TestEngine_Simulate_Deterministic(t *testing.T) { t.Parallel() diff --git a/internal/simulate/hotfix_action.go b/internal/simulate/hotfix_action.go new file mode 100644 index 00000000..9d1f7e93 --- /dev/null +++ b/internal/simulate/hotfix_action.go @@ -0,0 +1,179 @@ +package simulate + +import ( + "fmt" + "os" + + "github.com/stablekernel/cascade/internal/hotfix" + "github.com/stablekernel/cascade/internal/release" +) + +// HotfixAction replays the real hotfix finalize orchestration against a cloned +// manifest. It drives hotfix.Finalizer in record-only mode so the genuine +// divergence state machine runs: it allocates the next hotfix version, snapshots +// the prior state into the deploy-history ring, and writes the divergence fields +// (Ref, BaseSHA, Patches) to the clone only. Git, the trunk read, the manifest +// push, and the release API are all replaced with record-only seams, so the +// simulation touches no git and no network. +type HotfixAction struct { + targetEnv string + fixSHAs []string + mergeSHA string +} + +// NewHotfixAction builds a HotfixAction that simulates landing fixSHAs as a +// hotfix on targetEnv. mergeSHA is the resolution-branch tip the hotfix merges +// to; when empty it defaults to the first fix SHA, which is the common case for +// a single-commit hotfix. +func NewHotfixAction(targetEnv string, fixSHAs []string, mergeSHA string) *HotfixAction { + return &HotfixAction{targetEnv: targetEnv, fixSHAs: fixSHAs, mergeSHA: mergeSHA} +} + +// Name returns the action identifier. +func (a *HotfixAction) Name() string { return "hotfix" } + +// Describe returns a one-line summary of the hotfix being simulated. +func (a *HotfixAction) Describe() string { + return fmt.Sprintf("hotfix (env=%s, commits=%d)", a.targetEnv, len(a.fixSHAs)) +} + +// Apply drives the real hotfix Finalizer against the clone manifest in +// record-only mode and returns the divergence after-state plus an ordered effect +// sequence. The Finalizer writes the diverged state to the clone via os.WriteFile +// only; the injected pusher and release manager record what the workflow would +// commit and publish without performing it. +func (a *HotfixAction) Apply(ctx ActionContext) (*ActionOutcome, error) { + if len(a.fixSHAs) == 0 { + return nil, fmt.Errorf("hotfix needs at least one fix commit") + } + + mergeSHA := a.mergeSHA + if mergeSHA == "" { + mergeSHA = a.fixSHAs[0] + } + + before, err := parseState(ctx.ClonePath) + if err != nil { + return nil, fmt.Errorf("parse clone state: %w", err) + } + cur := before[a.targetEnv] + if cur == nil || cur.SHA == "" { + return nil, fmt.Errorf("environment %q has no recorded state SHA to diverge from", a.targetEnv) + } + baseSHA := cur.SHA + + pusher := &recordingPusher{} + relMgr := &recordingReleaseManager{} + + finalizer, err := hotfix.NewFinalizer( + hotfix.FinalizerOptions{ConfigPath: ctx.ClonePath, Actor: ctx.Actor}, + hotfix.WithFinalizeDryRun(false), + hotfix.WithTipReader(fixedTip(mergeSHA)), + hotfix.WithTrunkStateReader(cloneTrunkReader{}), + hotfix.WithTagLister(noTags{}), + hotfix.WithStatePusher(pusher), + hotfix.WithReleaseManager(relMgr), + ) + if err != nil { + return nil, fmt.Errorf("build finalizer: %w", err) + } + + if err := finalizer.Finalize(a.targetEnv, mergeSHA, a.fixSHAs, baseSHA); err != nil { + return nil, fmt.Errorf("finalize hotfix: %w", err) + } + + return &ActionOutcome{ + Effects: a.effects(pusher, relMgr), + AfterStatePath: ctx.ClonePath, + }, nil +} + +// effects assembles the ordered effect sequence from the fix commits the hotfix +// carries and the calls the record-only seams captured. A finalize that recorded +// no commit/push was an idempotent no-op and yields a single skip effect. +func (a *HotfixAction) effects(pusher *recordingPusher, relMgr *recordingReleaseManager) []Effect { + if len(pusher.messages) == 0 { + return []Effect{{ + Disposition: DispositionSkip, + Action: "hotfix", + Target: a.targetEnv, + Detail: "already recorded; no change", + }} + } + + effects := make([]Effect, 0, len(a.fixSHAs)+1+len(relMgr.calls)) + for _, sha := range a.fixSHAs { + effects = append(effects, Effect{ + Disposition: DispositionRun, + Action: "apply patch", + Target: a.targetEnv, + Detail: fmt.Sprintf("commit %s", shortOrNone(sha)), + }) + } + effects = append(effects, Effect{ + Disposition: DispositionRun, + Action: "write state", + Target: a.targetEnv, + Detail: "diverge env onto integration branch", + }) + for _, opts := range relMgr.calls { + effects = append(effects, Effect{ + Disposition: DispositionRun, + Action: "release " + string(opts.Action), + Target: a.targetEnv, + Detail: fmt.Sprintf("tag %s", orNone(opts.Tag)), + }) + } + return effects +} + +// fixedTip is a record-only env-branch tip reader that always reports the merge +// SHA, so the Finalizer's merge-SHA-equals-tip cross-check passes without a git +// checkout. +type fixedTip string + +// LocalBranchSHA returns the fixed merge SHA for any branch name. +func (t fixedTip) LocalBranchSHA(string) (string, error) { return string(t), nil } + +// cloneTrunkReader is a record-only trunk-state reader that returns the clone +// manifest bytes, so the Finalizer reads prior state from the clone rather than +// from a real trunk branch. +type cloneTrunkReader struct{} + +// ReadManifest returns the bytes of the manifest at path, ignoring the trunk +// branch name. The path is the clone the engine handed the action. +func (cloneTrunkReader) ReadManifest(path, _ string) ([]byte, error) { + return os.ReadFile(path) +} + +// noTags is a record-only tag lister that reports no existing tags, so hotfix +// version allocation is deterministic and never reads a git repository. +type noTags struct{} + +// ListTags always returns an empty slice. +func (noTags) ListTags() ([]string, error) { return nil, nil } + +// recordingPusher captures the manifest commit messages the Finalizer would push +// to trunk without performing any git operation. +type recordingPusher struct { + messages []string +} + +// CommitAndPush records the commit message and reports success without touching +// git. The Finalizer has already written the clone before calling this. +func (p *recordingPusher) CommitAndPush(_, _, message string) error { + p.messages = append(p.messages, message) + return nil +} + +// recordingReleaseManager captures the release operations the Finalizer would +// run against GitHub without performing any network call. +type recordingReleaseManager struct { + calls []release.Options +} + +// Manage records the release options and returns an empty success result. +func (m *recordingReleaseManager) Manage(opts release.Options) (*release.Result, error) { + m.calls = append(m.calls, opts) + return &release.Result{}, nil +} diff --git a/internal/simulate/hotfix_action_test.go b/internal/simulate/hotfix_action_test.go new file mode 100644 index 00000000..ee61ce96 --- /dev/null +++ b/internal/simulate/hotfix_action_test.go @@ -0,0 +1,120 @@ +package simulate + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +// seedHotfixManifest writes a manifest whose uat env (the prerelease env in the +// dev->uat->prod chain) holds a parseable rc version and a recorded state SHA to +// diverge from. +func seedHotfixManifest(t *testing.T) string { + t.Helper() + + return writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "uat", "prod"}, + }, + State: map[string]*config.EnvState{ + "uat": { + SHA: "basesha000000", + Version: "v1.0.0-rc.1", + CommittedAt: "2026-01-01T10:00:00Z", + CommittedBy: "seed-user", + }, + }, + }) +} + +func TestHotfixAction_DivergesEnvAndRecordsPatch(t *testing.T) { + t.Parallel() + + path := seedHotfixManifest(t) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewHotfixAction("uat", []string{"fixaaa1110000"}, "")) + require.NoError(t, err) + + assert.Equal(t, "hotfix", result.ActionName) + + uat, ok := result.Diff.Env("uat") + require.True(t, ok) + assert.Equal(t, "basesha000000", uat.SHA.From) + assert.Equal(t, "fixaaa1110000", uat.SHA.To) + assert.True(t, uat.Version.Changed) + assert.Equal(t, "v1.0.0-rc.1.hotfix.1", uat.Version.To) + assert.True(t, uat.Divergence.Changed, "the env must go diverged") + assert.Equal(t, "yes", uat.Divergence.To) + assert.True(t, uat.PreviousRing.Changed, "the prior state is snapshotted into the ring") + + // apply patch -> write state -> release create -> release prerelease. + require.GreaterOrEqual(t, len(result.Effects), 4) + assert.Equal(t, "apply patch", result.Effects[0].Action) + assert.Equal(t, "uat", result.Effects[0].Target) + assert.Equal(t, "write state", result.Effects[1].Action) + assert.Equal(t, "release create", result.Effects[2].Action) + assert.Equal(t, "release prerelease", result.Effects[3].Action, + "hotfix on the prerelease env promotes the release object to prerelease") +} + +func TestHotfixAction_MultiCommitRecordsEveryPatch(t *testing.T) { + t.Parallel() + + path := seedHotfixManifest(t) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate( + NewHotfixAction("uat", []string{"fixaaa1110000", "fixbbb2220000"}, "mergesha00000")) + require.NoError(t, err) + + var applyPatch int + for _, e := range result.Effects { + if e.Action == "apply patch" { + applyPatch++ + } + } + assert.Equal(t, 2, applyPatch, "every carried commit yields an apply-patch effect") + + uat, ok := result.Diff.Env("uat") + require.True(t, ok) + assert.Equal(t, "mergesha00000", uat.SHA.To, "the env advances to the merge SHA") +} + +func TestHotfixAction_LeavesOriginalUntouched(t *testing.T) { + t.Parallel() + + path := seedHotfixManifest(t) + before, err := os.ReadFile(path) + require.NoError(t, err) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + _, err = engine.Simulate(NewHotfixAction("uat", []string{"fixaaa1110000"}, "")) + require.NoError(t, err) + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, before, after, "the original manifest bytes must be unchanged") +} + +func TestHotfixAction_NoStateErrors(t *testing.T) { + t.Parallel() + + path := seedHotfixManifest(t) + engine, err := NewEngine(path) + require.NoError(t, err) + + _, err = engine.Simulate(NewHotfixAction("prod", []string{"fixaaa1110000"}, "")) + require.Error(t, err) + assert.Contains(t, err.Error(), "no recorded state SHA") +} diff --git a/internal/simulate/promote_action.go b/internal/simulate/promote_action.go index 6e55be36..222a04ee 100644 --- a/internal/simulate/promote_action.go +++ b/internal/simulate/promote_action.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stablekernel/cascade/internal/promote" + "github.com/stablekernel/cascade/internal/release" ) // PromoteAction replays the real promotion orchestration against a cloned @@ -87,6 +88,10 @@ func effectsFromResult(result *promote.PromotionResult) []Effect { }) } + if marker, ok := releaseMarkerEffect(result); ok { + effects = append(effects, marker) + } + for _, env := range result.SkippedEnvs { effects = append(effects, Effect{ Disposition: DispositionSkip, @@ -99,6 +104,42 @@ func effectsFromResult(result *promote.PromotionResult) []Effect { return effects } +// releaseMarkerEffect translates the publish-marker step of a promotion into a +// distinct effect. The promoter sets result.ReleaseAction to "prerelease" or +// "publish" when a crossing reaches the release boundary; the review of the +// engine seam noted that step was folded into the generic write-state effect and +// could not be told apart. The action label is taken from the internal/release +// vocabulary so the simulator and the real release executor agree on the names. +// The ok return is false when the result carries no release marker. +func releaseMarkerEffect(result *promote.PromotionResult) (Effect, bool) { + if result == nil || result.ReleaseAction == "" { + return Effect{}, false + } + + act, err := release.ValidateAction(result.ReleaseAction) + if err != nil { + // An unrecognized marker is surfaced verbatim rather than dropped, so + // the operator still sees that a release step would run. + act = release.Action(result.ReleaseAction) + } + + target := "release" + detail := "release marker advance" + if data := result.ReleaseData; data != nil { + if data.SemVersion != "" { + target = data.SemVersion + } + detail = fmt.Sprintf("rc %s, sha %s", orNone(data.RCVersion), shortOrNone(data.SHA)) + } + + return Effect{ + Disposition: DispositionRun, + Action: "release " + string(act), + Target: target, + Detail: detail, + }, true +} + // shortOrNone renders the first 7 characters of a SHA, or (none) when empty. func shortOrNone(sha string) string { if sha == "" { diff --git a/internal/simulate/release_action.go b/internal/simulate/release_action.go new file mode 100644 index 00000000..079e159f --- /dev/null +++ b/internal/simulate/release_action.go @@ -0,0 +1,61 @@ +package simulate + +import ( + "fmt" + + "github.com/stablekernel/cascade/internal/promote" +) + +// ReleaseAction replays the real promotion state-machine across the release +// boundary against a cloned manifest. The promoter is the orchestration brain +// that decides whether a crossing is a prerelease promotion or a publish, and it +// computes the rc-to-publish version carry; this action drives it and surfaces +// that decision as the release-marker effect. +// +// The network-bound release executor (internal/release Manager) is not invoked: +// the simulator validates orchestration, meaning the state transitions and the +// release decision, not the GitHub release API calls. The marker effect labels +// the step with the internal/release action vocabulary so the simulator and the +// executor name the step identically. +type ReleaseAction struct{} + +// NewReleaseAction builds a ReleaseAction. +func NewReleaseAction() *ReleaseAction { return &ReleaseAction{} } + +// Name returns the action identifier. +func (a *ReleaseAction) Name() string { return "release" } + +// Describe returns a one-line summary of the release crossing being simulated. +func (a *ReleaseAction) Describe() string { return "release (prerelease/publish crossing)" } + +// Apply runs the real promoter in default mode against the clone manifest and +// returns the effect sequence including the release marker. It reports a clear +// error when the manifest state is not at a release boundary, so the operator +// learns the crossing would not produce a release rather than seeing an empty +// result. +func (a *ReleaseAction) Apply(ctx ActionContext) (*ActionOutcome, error) { + promoter, err := promote.NewPromoter(promote.PromoterOptions{ + ConfigPath: ctx.ClonePath, + DryRun: false, + Actor: ctx.Actor, + }) + if err != nil { + return nil, fmt.Errorf("build promoter: %w", err) + } + + result, err := promoter.Promote(promote.ModeDefault, "") + if err != nil { + return nil, fmt.Errorf("run release promotion: %w", err) + } + if result != nil && !result.Success && result.Error != "" { + return nil, fmt.Errorf("release promotion failed: %s", result.Error) + } + if result == nil || result.ReleaseAction == "" { + return nil, fmt.Errorf("no release crossing: the manifest state is not at the prerelease or publish boundary") + } + + return &ActionOutcome{ + Effects: effectsFromResult(result), + AfterStatePath: ctx.ClonePath, + }, nil +} diff --git a/internal/simulate/release_action_test.go b/internal/simulate/release_action_test.go new file mode 100644 index 00000000..ecfb2d9f --- /dev/null +++ b/internal/simulate/release_action_test.go @@ -0,0 +1,67 @@ +package simulate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +func findEffect(effects []Effect, action string) (Effect, bool) { + for _, e := range effects { + if e.Action == action { + return e, true + } + } + return Effect{}, false +} + +func TestReleaseAction_SurfacesPrereleaseMarker(t *testing.T) { + t.Parallel() + + // dev populated, uat (the prerelease env) empty: the default crossing into + // uat sets ReleaseAction=prerelease. + path := writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "uat", "prod"}}, + State: map[string]*config.EnvState{ + "dev": {SHA: "a1b2c3d4e5f6", Version: "v1.0.0-rc.0"}, + "uat": {}, + }, + }) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewReleaseAction()) + require.NoError(t, err) + + assert.Equal(t, "release", result.ActionName) + + marker, ok := findEffect(result.Effects, "release prerelease") + require.True(t, ok, "the prerelease marker must appear as its own effect") + assert.Equal(t, "v1.0.0", marker.Target, "the target is the semver being marked") + assert.Equal(t, DispositionRun, marker.Disposition) +} + +func TestReleaseAction_ErrorsWhenNoCrossing(t *testing.T) { + t.Parallel() + + // A normal early hop that does not reach the prerelease env: dev advances to + // staging, but staging is not the release boundary, so no marker is emitted. + path := writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "staging", "uat", "prod"}}, + State: map[string]*config.EnvState{ + "dev": {SHA: "a1b2c3d4e5f6", Version: "v1.0.0-rc.0"}, + "staging": {}, + }, + }) + + engine, err := NewEngine(path) + require.NoError(t, err) + + _, err = engine.Simulate(NewReleaseAction()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no release crossing") +} diff --git a/internal/simulate/rollback_action.go b/internal/simulate/rollback_action.go new file mode 100644 index 00000000..5bd2e717 --- /dev/null +++ b/internal/simulate/rollback_action.go @@ -0,0 +1,119 @@ +package simulate + +import ( + "fmt" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/rollback" +) + +// RollbackAction replays the real rollback orchestration against a cloned +// manifest. It drives rollback.New, Plan, and Apply so the genuine target +// resolution and state revert run, writing the reverted state to the clone only. +type RollbackAction struct { + env string + to string + deployable string +} + +// NewRollbackAction builds a RollbackAction for the given environment. The to +// argument selects a prior SHA or version; when empty the previous distinct +// state is resolved from the deploy-history ring. The deployable argument scopes +// the rollback to a single deploy when set. +func NewRollbackAction(env, to, deployable string) *RollbackAction { + return &RollbackAction{env: env, to: to, deployable: deployable} +} + +// Name returns the action identifier. +func (a *RollbackAction) Name() string { return "rollback" } + +// Describe returns a one-line summary of the rollback being simulated. +func (a *RollbackAction) Describe() string { + target := a.to + if target == "" { + target = "previous" + } + if a.deployable != "" { + return fmt.Sprintf("rollback (env=%s, to=%s, deployable=%s)", a.env, target, a.deployable) + } + return fmt.Sprintf("rollback (env=%s, to=%s)", a.env, target) +} + +// Apply resolves the rollback target against the clone manifest and applies it. +// History resolution is pinned to the in-state deploy-history ring by injecting +// an empty history reader, so the simulation never shells out to git. Apply +// writes the reverted state to the clone path via os.WriteFile only. +func (a *RollbackAction) Apply(ctx ActionContext) (*ActionOutcome, error) { + rb, err := rollback.New(rollback.Options{ + ConfigPath: ctx.ClonePath, + Actor: ctx.Actor, + HistoryReader: emptyHistory{}, + }) + if err != nil { + return nil, fmt.Errorf("build rollbacker: %w", err) + } + + plan, err := rb.Plan(a.env, a.to, a.deployable) + if err != nil { + return nil, fmt.Errorf("plan rollback: %w", err) + } + + if err := rb.Apply(plan); err != nil { + return nil, fmt.Errorf("apply rollback: %w", err) + } + + return &ActionOutcome{ + Effects: effectsFromRollback(plan), + AfterStatePath: ctx.ClonePath, + }, nil +} + +// effectsFromRollback translates a rollback plan into the ordered effect +// sequence. A no-op plan yields a single skip effect; a real rollback yields a +// revert effect followed by a write-state effect. The mapping reads only the +// plan the real package computed and invents no steps. +func effectsFromRollback(plan *rollback.Plan) []Effect { + if plan == nil { + return nil + } + + target := plan.Environment + if plan.Deployable != "" { + target = fmt.Sprintf("%s/%s", plan.Environment, plan.Deployable) + } + + if plan.NoOp { + return []Effect{{ + Disposition: DispositionSkip, + Action: "rollback", + Target: target, + Detail: "already at target", + }} + } + + return []Effect{ + { + Disposition: DispositionRun, + Action: "revert", + Target: target, + Detail: fmt.Sprintf("to sha %s, version %s (from %s)", + shortOrNone(plan.Target.SHA), orNone(plan.Target.Version), plan.Target.Source), + }, + { + Disposition: DispositionRun, + Action: "write state", + Target: target, + Detail: fmt.Sprintf("sha %s, version %s", shortOrNone(plan.Target.SHA), orNone(plan.Target.Version)), + }, + } +} + +// emptyHistory is a record-only rollback.HistoryReader that reports no git +// history. It pins target resolution to the in-state deploy-history ring so the +// simulation is deterministic and never reads a git repository. +type emptyHistory struct{} + +// PriorStates always returns an empty slice, signaling no recoverable history. +func (emptyHistory) PriorStates(string) ([]*config.EnvState, error) { + return nil, nil +} diff --git a/internal/simulate/rollback_action_test.go b/internal/simulate/rollback_action_test.go new file mode 100644 index 00000000..1c7c1cd5 --- /dev/null +++ b/internal/simulate/rollback_action_test.go @@ -0,0 +1,150 @@ +package simulate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/rollback" +) + +// writeManifest marshals a CICDFile under the default manifest key and returns +// its path in a fresh temp dir. +func writeManifest(t *testing.T, cicd *config.CICDFile) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + data, err := yaml.Marshal(map[string]interface{}{"ci": cicd}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) + return path +} + +// seedRollbackManifest writes a manifest whose prod env holds a current state +// and a single distinct prior snapshot in the deploy-history ring. +func seedRollbackManifest(t *testing.T) string { + t.Helper() + + return writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "uat", "prod"}, + }, + State: map[string]*config.EnvState{ + "prod": { + SHA: "newsha0000000", + Version: "v2.0.0", + CommittedAt: "2026-02-01T10:00:00Z", + CommittedBy: "seed-user", + Previous: []config.EnvStateSnapshot{ + {SHA: "oldsha0000000", Version: "v1.0.0", CommittedAt: "2026-01-01T10:00:00Z", CommittedBy: "seed-user"}, + }, + }, + }, + }) +} + +func TestRollbackAction_RevertsToPreviousRingSnapshot(t *testing.T) { + t.Parallel() + + path := seedRollbackManifest(t) + + // Compute the real package's plan independently so the assertion compares + // against what rollback itself resolves, not a hand-rolled expectation. + planner, err := rollback.New(rollback.Options{ConfigPath: path, HistoryReader: emptyHistory{}}) + require.NoError(t, err) + plan, err := planner.Plan("prod", "", "") + require.NoError(t, err) + require.Equal(t, "oldsha0000000", plan.Target.SHA) + require.Equal(t, "previous-ring", plan.Target.Source) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewRollbackAction("prod", "", "")) + require.NoError(t, err) + + assert.Equal(t, "rollback", result.ActionName) + + prod, ok := result.Diff.Env("prod") + require.True(t, ok) + assert.Equal(t, "newsha0000000", prod.SHA.From) + assert.Equal(t, "oldsha0000000", prod.SHA.To) + assert.Equal(t, "v2.0.0", prod.Version.From) + assert.Equal(t, "v1.0.0", prod.Version.To) + assert.True(t, prod.Divergence.Changed, "a rollback marks the env diverged off trunk") + assert.Equal(t, "yes", prod.Divergence.To) + + require.Len(t, result.Effects, 2) + assert.Equal(t, DispositionRun, result.Effects[0].Disposition) + assert.Equal(t, "revert", result.Effects[0].Action) + assert.Equal(t, "prod", result.Effects[0].Target) + assert.Equal(t, "write state", result.Effects[1].Action) +} + +func TestRollbackAction_NoOpWhenAlreadyAtTarget(t *testing.T) { + t.Parallel() + + // No distinct prior: the only snapshot equals the current SHA, so the + // resolved target is the current state and the rollback is a no-op. + path := writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}}, + State: map[string]*config.EnvState{ + "prod": { + SHA: "samesha000000", + Version: "v1.0.0", + Previous: []config.EnvStateSnapshot{ + {SHA: "samesha000000", Version: "v1.0.0"}, + }, + }, + }, + }) + + engine, err := NewEngine(path) + require.NoError(t, err) + + // With no distinct prior the default resolution errors; target an explicit + // SHA equal to current to exercise the no-op path deterministically. + result, err := engine.Simulate(NewRollbackAction("prod", "samesha000000", "")) + require.NoError(t, err) + + assert.False(t, result.Diff.Changed(), "a no-op rollback changes nothing") + require.Len(t, result.Effects, 1) + assert.Equal(t, DispositionSkip, result.Effects[0].Disposition) + assert.Equal(t, "prod", result.Effects[0].Target) +} + +func TestRollbackAction_LeavesOriginalUntouched(t *testing.T) { + t.Parallel() + + path := seedRollbackManifest(t) + before, err := os.ReadFile(path) + require.NoError(t, err) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + _, err = engine.Simulate(NewRollbackAction("prod", "", "")) + require.NoError(t, err) + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, before, after, "the original manifest bytes must be unchanged") +} + +func TestRollbackAction_UnknownEnvErrors(t *testing.T) { + t.Parallel() + + path := seedRollbackManifest(t) + engine, err := NewEngine(path) + require.NoError(t, err) + + _, err = engine.Simulate(NewRollbackAction("nope", "", "")) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown environment") +} diff --git a/internal/simulate/testdata/promote_human.golden b/internal/simulate/testdata/promote_human.golden index de9798fd..8831c938 100644 --- a/internal/simulate/testdata/promote_human.golden +++ b/internal/simulate/testdata/promote_human.golden @@ -6,4 +6,5 @@ State diff: Effects (in order): 1. [run] deploy uat (from dev (sha a1b2c3d, version v1.2.0-rc.1)) 2. [run] write state uat (sha a1b2c3d, version v1.2.0-rc.1) - 3. [skip] promote prod (no change required) + 3. [run] release prerelease v1.2.0 (rc v1.2.0-rc.1, sha a1b2c3d) + 4. [skip] promote prod (no change required) diff --git a/internal/simulate/testdata/promote_json.golden b/internal/simulate/testdata/promote_json.golden index eaed207f..a256262d 100644 --- a/internal/simulate/testdata/promote_json.golden +++ b/internal/simulate/testdata/promote_json.golden @@ -45,6 +45,12 @@ "target": "uat", "detail": "sha a1b2c3d, version v1.2.0-rc.1" }, + { + "disposition": "run", + "action": "release prerelease", + "target": "v1.2.0", + "detail": "rc v1.2.0-rc.1, sha a1b2c3d" + }, { "disposition": "skip", "action": "promote", From 77dc115dcc3588afb88807abce9cb09085f98dff Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 25 Jun 2026 13:35:45 -0400 Subject: [PATCH 2/2] fix(simulate): drop summed-length make cap flagged as overflow-prone Signed-off-by: Joshua Temple --- internal/simulate/hotfix_action.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/simulate/hotfix_action.go b/internal/simulate/hotfix_action.go index 9d1f7e93..f3cf5c8d 100644 --- a/internal/simulate/hotfix_action.go +++ b/internal/simulate/hotfix_action.go @@ -101,7 +101,7 @@ func (a *HotfixAction) effects(pusher *recordingPusher, relMgr *recordingRelease }} } - effects := make([]Effect, 0, len(a.fixSHAs)+1+len(relMgr.calls)) + effects := make([]Effect, 0) for _, sha := range a.fixSHAs { effects = append(effects, Effect{ Disposition: DispositionRun,