From 4db11f7520e9160e3a060c76090bbaf0ae0e826d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 10 Jun 2026 22:54:57 -0400 Subject: [PATCH 1/2] test(promote): add failing guards for hotfix divergence promotion rules Signed-off-by: Joshua Temple --- internal/promote/guards_test.go | 308 ++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 internal/promote/guards_test.go diff --git a/internal/promote/guards_test.go b/internal/promote/guards_test.go new file mode 100644 index 00000000..609bde22 --- /dev/null +++ b/internal/promote/guards_test.go @@ -0,0 +1,308 @@ +package promote + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/config" +) + +// writeGuardConfig writes a CICDFile to a temp manifest and returns its path. +func writeGuardConfig(t *testing.T, environments []string, state map[string]*config.EnvState) string { + t.Helper() + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "cicd.yaml") + + cicdFile := &config.CICDFile{ + Config: &config.TrunkConfig{ + TrunkBranch: "main", + Environments: environments, + }, + State: state, + } + wrapper := map[string]interface{}{"ci": cicdFile} + data, err := yaml.Marshal(wrapper) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + return configPath +} + +// containsAllOf reports whether s contains every substring in subs. +func containsAllOf(s string, subs ...string) bool { + for _, sub := range subs { + if !strings.Contains(s, sub) { + return false + } + } + return true +} + +// allContainAncestor is a stub ancestor function that always reports the +// ancestor is contained in the descendant (every patch is present). +func allContainAncestor(_, _ string) (bool, error) { return true, nil } + +// noneContainAncestor is a stub ancestor function that always reports the +// ancestor is missing from the descendant (a patch would be regressed). +func noneContainAncestor(_, _ string) (bool, error) { return false, nil } + +// --- Rule 1: refuse promotion FROM a diverged env --- + +func TestPromote_FromDivergedEnv_Blocked(t *testing.T) { + state := map[string]*config.EnvState{ + // test is diverged on an integration branch carrying a hotfix patch. + "test": { + SHA: "mergesha", + Version: "v1.4.0-rc.2.hotfix.1", + Ref: "env/test", + BaseSHA: "basesha", + Patches: []string{"patchsha1"}, + }, + } + configPath := writeGuardConfig(t, []string{"dev", "test", "uat", "prod"}, state) + + p, err := NewPromoter(PromoterOptions{ConfigPath: configPath, DryRun: true, Actor: "test"}) + if err != nil { + t.Fatalf("NewPromoter: %v", err) + } + + result, err := p.Promote(ModeDefault, "") + if err != nil { + t.Fatalf("Promote returned error: %v", err) + } + if result.Success { + t.Fatalf("expected promotion FROM diverged env to be blocked, got success") + } + if !containsAllOf(result.Error, "test", "patchsha1") { + t.Errorf("error should name the diverged env and patches, got: %q", result.Error) + } +} + +func TestPromote_FromDivergedEnv_Blocked_Cascade(t *testing.T) { + state := map[string]*config.EnvState{ + "test": { + SHA: "mergesha", + Version: "v1.4.0-rc.2.hotfix.1", + Ref: "env/test", + BaseSHA: "basesha", + Patches: []string{"patchsha1"}, + }, + } + configPath := writeGuardConfig(t, []string{"dev", "test", "uat", "prod"}, state) + + p, err := NewPromoter(PromoterOptions{ConfigPath: configPath, DryRun: true, Actor: "test"}) + if err != nil { + t.Fatalf("NewPromoter: %v", err) + } + + result, err := p.Promote(ModeCascade, "test-to-prod") + if err != nil { + t.Fatalf("Promote returned error: %v", err) + } + if result.Success { + t.Fatalf("expected cascade promotion FROM diverged env to be blocked, got success") + } + if !containsAllOf(result.Error, "test", "patchsha1") { + t.Errorf("error should name the diverged env and patches, got: %q", result.Error) + } +} + +// --- Rule 2: promotion INTO a diverged env requires patch containment --- + +func TestPromote_IntoDivergedEnv_MissingPatch_Blocked(t *testing.T) { + // uat is diverged. Promoting dev (which lacks the patch) into uat must fail. + state := map[string]*config.EnvState{ + "uat": { + SHA: "uatmerge", + Version: "v1.4.0-rc.1.hotfix.1", + Ref: "env/uat", + BaseSHA: "uatbase", + Patches: []string{"patchsha1"}, + }, + "test": {SHA: "incoming", Version: "v1.5.0-rc.0"}, + } + cfg := &config.CICDFile{ + Config: &config.TrunkConfig{ + Environments: []string{"dev", "test", "uat", "prod"}, + }, + State: state, + } + + pf := NewPreflighter(PreflighterOptions{ + Config: cfg, + Mode: ModeCascade, + Target: "test-to-uat", + }, WithAncestorFunc(noneContainAncestor)) + + _, err := pf.Run() + if err == nil { + t.Fatalf("expected preflight to block promotion into diverged env missing a patch") + } + if !containsAllOf(err.Error(), "patchsha1") { + t.Errorf("error should name the missing patch, got: %q", err.Error()) + } +} + +func TestPromote_IntoDivergedEnv_PatchesContained_Allowed(t *testing.T) { + state := map[string]*config.EnvState{ + "uat": { + SHA: "uatmerge", + Version: "v1.4.0-rc.1.hotfix.1", + Ref: "env/uat", + BaseSHA: "uatbase", + Patches: []string{"patchsha1"}, + }, + "test": {SHA: "incoming", Version: "v1.5.0-rc.0"}, + } + cfg := &config.CICDFile{ + Config: &config.TrunkConfig{ + Environments: []string{"dev", "test", "uat", "prod"}, + }, + State: state, + } + + pf := NewPreflighter(PreflighterOptions{ + Config: cfg, + Mode: ModeCascade, + Target: "test-to-uat", + }, WithAncestorFunc(allContainAncestor)) + + result, err := pf.Run() + if err != nil { + t.Fatalf("expected promotion with contained patches to be allowed, got: %v", err) + } + if !result.CanProceed { + t.Errorf("expected CanProceed=true when all patches are contained") + } +} + +func TestPromote_IntoDivergedEnv_Force_OverridesWithWarning(t *testing.T) { + state := map[string]*config.EnvState{ + "uat": { + SHA: "uatmerge", + Version: "v1.4.0-rc.1.hotfix.1", + Ref: "env/uat", + BaseSHA: "uatbase", + Patches: []string{"patchsha1"}, + }, + "test": {SHA: "incoming", Version: "v1.5.0-rc.0"}, + } + cfg := &config.CICDFile{ + Config: &config.TrunkConfig{ + Environments: []string{"dev", "test", "uat", "prod"}, + }, + State: state, + } + + pf := NewPreflighter(PreflighterOptions{ + Config: cfg, + Mode: ModeCascade, + Target: "test-to-uat", + Force: true, + }, WithAncestorFunc(noneContainAncestor)) + + result, err := pf.Run() + if err != nil { + t.Fatalf("force should override the patch-containment block, got: %v", err) + } + if result == nil || !result.CanProceed { + t.Fatalf("expected CanProceed=true under force override") + } + if !containsAllOf(strings.Join(result.Warnings, "\n"), "patchsha1") { + t.Errorf("force override should emit a loud warning naming the regressed patch, got warnings: %v", result.Warnings) + } +} + +// --- Rule 3: publish-path assertion --- + +func TestPublish_DivergedSource_Asserts(t *testing.T) { + // prerelease env (uat) is diverged; advancing to the publish boundary must + // refuse because a non-trunk SHA must never reach publish. + state := map[string]*config.EnvState{ + "uat": { + SHA: "uatmerge", + Version: "v1.4.0-rc.2.hotfix.1", + Ref: "env/uat", + BaseSHA: "uatbase", + Patches: []string{"patchsha1"}, + }, + } + configPath := writeGuardConfig(t, []string{"dev", "test", "uat", "prod"}, state) + + p, err := NewPromoter(PromoterOptions{ConfigPath: configPath, DryRun: true, Actor: "test"}) + if err != nil { + t.Fatalf("NewPromoter: %v", err) + } + + result, err := p.Promote(ModeDefault, "") + if err != nil { + t.Fatalf("Promote returned error: %v", err) + } + if result.Success { + t.Fatalf("expected publish from a diverged source to be asserted/blocked, got success") + } + if !containsAllOf(result.Error, "uat") { + t.Errorf("publish assertion should name the diverged source env, got: %q", result.Error) + } +} + +func TestPublish_DivergedSource_Asserts_NoEnvironment(t *testing.T) { + // Library/CLI mode: prerelease state is diverged; publish must refuse. + state := map[string]*config.EnvState{ + "prerelease": { + SHA: "mergesha", + Version: "v1.4.0-rc.2.hotfix.1", + Ref: "env/prerelease", + BaseSHA: "basesha", + Patches: []string{"patchsha1"}, + }, + } + configPath := writeGuardConfig(t, nil, state) + + p, err := NewPromoter(PromoterOptions{ConfigPath: configPath, DryRun: true, Actor: "test"}) + if err != nil { + t.Fatalf("NewPromoter: %v", err) + } + + result, err := p.Promote(ModeDefault, "") + if err != nil { + t.Fatalf("Promote returned error: %v", err) + } + if result.Success { + t.Fatalf("expected library-mode publish from diverged prerelease to be blocked, got success") + } + if !containsAllOf(result.Error, "prerelease") { + t.Errorf("publish assertion should name the diverged source, got: %q", result.Error) + } +} + +// --- Additivity: a manifest with no divergence fields exercises zero new paths --- + +func TestPromote_NoDivergence_GuardsInert(t *testing.T) { + state := map[string]*config.EnvState{ + "dev": {SHA: "sha3", Version: "v1.3.0-rc.0"}, + "test": {SHA: "sha2", Version: "v1.2.0-rc.0"}, + "uat": {SHA: "sha1", Version: "v1.1.0-rc.0"}, + } + configPath := writeGuardConfig(t, []string{"dev", "test", "uat", "prod"}, state) + + p, err := NewPromoter(PromoterOptions{ConfigPath: configPath, DryRun: true, Actor: "test"}) + if err != nil { + t.Fatalf("NewPromoter: %v", err) + } + + result, err := p.Promote(ModeDefault, "") + if err != nil { + t.Fatalf("Promote returned error: %v", err) + } + if !result.Success { + t.Fatalf("non-diverged manifest must promote normally, got error: %q", result.Error) + } +} From a9ac584b29e3d914ea90b592559213874750e9a7 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 10 Jun 2026 22:57:54 -0400 Subject: [PATCH 2/2] feat(promote): guard promotion against diverged environments Add three additive preflight guards for hotfix divergence: refuse promotion from a diverged env, require the incoming SHA to contain every recorded patch when promoting into a diverged env (force overrides with a loud warning), and assert the publish path never sources from a diverged env. Guards fire only when divergence fields are present, so manifests without them are unaffected. The git-ancestry checker is injected via a functional option. Signed-off-by: Joshua Temple --- internal/promote/guards.go | 64 ++++++++++++++++++++++++++++++ internal/promote/preflight.go | 74 +++++++++++++++++++++++++++++++++-- internal/promote/promote.go | 49 ++++++++++++++++++++++- 3 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 internal/promote/guards.go diff --git a/internal/promote/guards.go b/internal/promote/guards.go new file mode 100644 index 00000000..343b4eba --- /dev/null +++ b/internal/promote/guards.go @@ -0,0 +1,64 @@ +package promote + +import ( + "fmt" + "strings" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/git" +) + +// AncestorFunc reports whether ancestor is contained in (is an ancestor of) +// descendant. It mirrors git.IsAncestor and exists so the divergence guards can +// be exercised deterministically in tests without a real object store. +type AncestorFunc func(ancestor, descendant string) (bool, error) + +// Option customizes optional, additive behavior on a Promoter or Preflighter. +// Required inputs stay positional on the constructor; cross-cutting concerns +// (such as the git-ancestry checker) are threaded through options so new +// capability never changes an existing signature. +type Option func(*guardConfig) + +// guardConfig holds the resolved optional behavior shared by Promoter and +// Preflighter. The zero value is never used directly; constructors seed it with +// production defaults before applying caller options. +type guardConfig struct { + ancestor AncestorFunc +} + +// WithAncestorFunc overrides the git-ancestry checker used by the promotion +// divergence guards. The default is git.IsAncestor; tests inject a stub so the +// patch-containment rule can be exercised without a populated repository. +func WithAncestorFunc(fn AncestorFunc) Option { + return func(c *guardConfig) { + if fn != nil { + c.ancestor = fn + } + } +} + +// newGuardConfig resolves options over the production defaults. +func newGuardConfig(opts ...Option) guardConfig { + cfg := guardConfig{ancestor: git.IsAncestor} + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// divergedSourceError builds the error returned when a promotion would read its +// source from a diverged environment. The message names the env, its +// integration branch, the carried patches, and the escape hatches, so the +// failure is actionable in the preflight log rather than mid-deploy. +func divergedSourceError(env string, state *config.EnvState) error { + ref := state.Ref + if ref == "" { + ref = "env/" + env + } + return fmt.Errorf( + "cannot promote from diverged environment %q (integration branch %s carries non-trunk patches %s): "+ + "a diverged env holds a non-trunk SHA that must not propagate upward. "+ + "Escape hatches: promote from a lower environment, or direct-promote a trunk SHA that already contains the patches", + env, ref, strings.Join(state.Patches, ", "), + ) +} diff --git a/internal/promote/preflight.go b/internal/promote/preflight.go index fb96b8a0..c2e9e15b 100644 --- a/internal/promote/preflight.go +++ b/internal/promote/preflight.go @@ -56,6 +56,10 @@ type PreflightResult struct { CanProceed bool `json:"can_proceed"` BreakingBlockedAt string `json:"breaking_blocked_at,omitempty"` // The transition that was blocked + // Warnings carries non-fatal advisories surfaced during preflight, for + // example a forced override of the diverged-env patch-containment guard. + Warnings []string `json:"warnings,omitempty"` + // Full promotion result for downstream use PromotionResult *PromotionResult `json:"promotion_result"` } @@ -70,6 +74,7 @@ type Preflighter struct { deployChecks map[string]bool // deploy name -> include in run deploysFilter []string // Specific deploys to include (empty = all) rollbackOnFailure bool // Revert successful deploys if any fails + ancestor AncestorFunc // git-ancestry checker for divergence guards } // PreflighterOptions configures the Preflighter @@ -83,12 +88,15 @@ type PreflighterOptions struct { RollbackOnFailure bool // Revert successful deploys if any fails } -// NewPreflighter creates a new Preflighter instance -func NewPreflighter(opts PreflighterOptions) *Preflighter { +// NewPreflighter creates a new Preflighter instance. Optional behavior (such as +// the git-ancestry checker used by the divergence guards) is supplied through +// functional options so the required inputs stay positional. +func NewPreflighter(opts PreflighterOptions, options ...Option) *Preflighter { baseDir := opts.BaseDir if baseDir == "" { baseDir = "." } + gc := newGuardConfig(options...) return &Preflighter{ cicdFile: opts.Config, mode: opts.Mode, @@ -98,6 +106,7 @@ func NewPreflighter(opts PreflighterOptions) *Preflighter { deployChecks: make(map[string]bool), deploysFilter: opts.DeploysFilter, rollbackOnFailure: opts.RollbackOnFailure, + ancestor: gc.ancestor, } } @@ -108,12 +117,15 @@ func (p *Preflighter) SetDeployCheck(name string, include bool) { // Run executes the preflight checks and returns the result func (p *Preflighter) Run() (*PreflightResult, error) { - // 1. Calculate promotion plan using Promoter in dry-run mode + // 1. Calculate promotion plan using Promoter in dry-run mode. The promoter + // enforces the "never promote FROM a diverged env" and publish-path guards; + // it shares this preflighter's ancestry checker so behavior is consistent. promoter := &Promoter{ cicdFile: p.cicdFile, dryRun: true, // Always dry run for preflight actor: "preflight", force: p.force, + ancestor: p.ancestor, } promoResult, err := promoter.Promote(p.mode, p.target) @@ -224,9 +236,65 @@ func (p *Preflighter) Run() (*PreflightResult, error) { result.CanProceed = !result.HasBreaking + // 7. Promotion INTO a diverged env: the incoming SHA must contain every + // recorded patch, otherwise the promotion would silently regress a fix that + // was deployed into that env. The force flag overrides with a loud warning. + // Inert for non-diverged targets (no manifest without divergence fields can + // enter this loop). + if err := p.checkPatchContainment(promoResult.Promotions, result); err != nil { + return nil, err + } + return result, nil } +// checkPatchContainment enforces the "promote INTO a diverged env requires the +// incoming SHA to contain every recorded patch" rule across the planned +// promotions. For each target env that is diverged, every entry of its Patches +// must be an ancestor of the incoming SHA. A missing patch fails the preflight +// unless force is set, in which case it records a loud warning and proceeds. +// +// The check is fully additive: environments without divergence fields are +// skipped, so a manifest that never diverges never invokes the ancestry checker. +func (p *Preflighter) checkPatchContainment(promotions []EnvPromotion, result *PreflightResult) error { + for _, promo := range promotions { + target := p.cicdFile.State[promo.Environment] + if !target.IsDiverged() { + continue + } + + incoming := promo.SHA + for _, patch := range target.Patches { + contained, err := p.ancestor(patch, incoming) + if err != nil { + return fmt.Errorf( + "failed to verify patch %s is contained in %s for diverged environment %q: %w", + patch, incoming, promo.Environment, err, + ) + } + if contained { + continue + } + + if !p.force { + return fmt.Errorf( + "cannot promote into diverged environment %q: incoming SHA %s does not contain patch %s. "+ + "Promoting would regress a fix deployed in %q. "+ + "Promote a trunk SHA at or after the patch, or pass --force to override", + promo.Environment, incoming, patch, promo.Environment, + ) + } + + result.Warnings = append(result.Warnings, fmt.Sprintf( + "FORCE OVERRIDE: promoting into diverged environment %q with incoming SHA %s that does NOT contain patch %s; "+ + "this regresses a fix deployed in %q", + promo.Environment, incoming, patch, promo.Environment, + )) + } + } + return nil +} + // detectDeployChanges determines which deploys need to run based on changes and deploy checks // Returns (localDeploys, externalDeploys) func (p *Preflighter) detectDeployChanges(sourceSHA, targetEnv string) ([]string, []string) { diff --git a/internal/promote/promote.go b/internal/promote/promote.go index a2b954ea..12b7a588 100644 --- a/internal/promote/promote.go +++ b/internal/promote/promote.go @@ -65,6 +65,7 @@ type Promoter struct { dryRun bool actor string force bool // For default mode: continue on failure + ancestor AncestorFunc } // PromoterOptions configures the Promoter @@ -75,8 +76,10 @@ type PromoterOptions struct { Force bool // For default mode: continue on failure } -// NewPromoter creates a new Promoter -func NewPromoter(opts PromoterOptions) (*Promoter, error) { +// NewPromoter creates a new Promoter. Optional behavior (such as the +// git-ancestry checker used by the divergence guards) is supplied through +// functional options so the required inputs stay positional. +func NewPromoter(opts PromoterOptions, options ...Option) (*Promoter, error) { cicdFile, err := config.ParseManifestFile(opts.ConfigPath, config.DefaultManifestKey) if err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) @@ -87,12 +90,15 @@ func NewPromoter(opts PromoterOptions) (*Promoter, error) { actor = "github-actions[bot]" } + gc := newGuardConfig(options...) + return &Promoter{ configPath: opts.ConfigPath, cicdFile: cicdFile, dryRun: opts.DryRun, actor: actor, force: opts.Force, + ancestor: gc.ancestor, }, nil } @@ -195,6 +201,15 @@ func (p *Promoter) defaultPromotion() (*PromotionResult, error) { continue } + // Guard: never promote FROM a diverged env. Its SHA is not on trunk and + // must not propagate upward. Only fires when divergence fields are set, + // so non-diverged manifests never reach this branch. + if sourceState.IsDiverged() { + result.Success = false + result.Error = divergedSourceError(sourceEnv, sourceState).Error() + return result, nil + } + targetState := preState[targetEnv] // Skip if already in sync (no-op) @@ -286,6 +301,14 @@ func (p *Promoter) defaultPromotion() (*PromotionResult, error) { if releaseIdx > 0 && prereleaseEnv != "" { sourceState := preState[prereleaseEnv] if sourceState != nil && sourceState.SHA != "" { + // Publish-path assertion: the SHA reaching the release marker (and + // thus publish) must come from a non-diverged source. Inert unless + // the prerelease env carries divergence fields. + if sourceState.IsDiverged() { + result.Success = false + result.Error = divergedSourceError(prereleaseEnv, sourceState).Error() + return result, nil + } releaseState := preState["release"] if releaseState == nil || releaseState.SHA != sourceState.SHA { if !p.dryRun { @@ -390,6 +413,16 @@ func (p *Promoter) noEnvironmentPromotion() (*PromotionResult, error) { }, nil } + // Publish-path assertion: a diverged prerelease holds a non-trunk SHA and + // must never be published. Inert unless divergence fields are set. + if sourceState.IsDiverged() { + return &PromotionResult{ + Success: false, + Mode: ModeDefault, + Error: divergedSourceError("prerelease", sourceState).Error(), + }, nil + } + // Check if already released (release state matches prerelease) releaseState := p.cicdFile.State["release"] if releaseState != nil && releaseState.SHA == sourceState.SHA { @@ -479,6 +512,18 @@ func (p *Promoter) cascadePromotion(target string) (*PromotionResult, error) { }, nil } + // Guard: never cascade FROM a diverged env. Inert unless divergence fields + // are set on the source state. + if sourceState.IsDiverged() { + return &PromotionResult{ + Success: false, + Mode: ModeCascade, + Target: target, + IsCascade: true, + Error: divergedSourceError(sourceEnv, sourceState).Error(), + }, nil + } + envs := p.cicdFile.Config.Environments sourceIdx := indexOf(envs, sourceEnv) targetIdx := indexOf(envs, targetEnv)