diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index fa58874..b664e1d 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -48,6 +48,10 @@ on: description: 'Revert successful deploys if any fails (atomic promotion)' type: boolean default: true + allow_downgrade: + description: 'Permit promoting an older version (downgrade); prod always requires this' + type: boolean + default: false permissions: contents: write @@ -102,6 +106,7 @@ jobs: ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }} DEPLOYS: ${{ github.event.inputs.deploys }} ROLLBACK_ON_FAILURE: ${{ github.event.inputs.rollback_on_failure }} + ALLOW_DOWNGRADE: ${{ github.event.inputs.allow_downgrade }} run: | cascade promote preflight \ --mode "${PROMOTION_MODE:-default}" \ @@ -110,6 +115,7 @@ jobs: --allow-breaking="${ALLOW_BREAKING:-false}" \ --deploys="${DEPLOYS:-all}" \ --rollback-on-failure="${ROLLBACK_ON_FAILURE:-true}" \ + --allow-downgrade="${ALLOW_DOWNGRADE:-false}" \ --gha-output - name: Fail if Cannot Proceed if: steps.preflight.outputs.can_proceed == 'false' diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index 89640fa..6e8a516 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -331,6 +331,16 @@ cascade promote preflight \ | `--allow-breaking` | bool | false | Allow breaking changes past the prerelease boundary | | `--deploys` | string | `all` | Deploys to promote (comma-separated names or `all`) | | `--rollback-on-failure` | bool | true | Revert successful deploys if any fails | +| `--allow-downgrade` | bool | false | Permit promoting an older version onto an env (a downgrade). Blocked by default; prod always requires this flag | + +A promotion that would place a strictly older semver version onto an env than the +version it currently holds is a downgrade. Preflight blocks it by default, naming +both versions and the env. Pass `--allow-downgrade` to permit it. The terminal +(prod) env always requires the flag, even when a lower env in the same cascade +already permitted the same downgrade. Equal versions are an idempotent +re-promote and are never treated as a downgrade. When either version is not +parseable as semver the gate fails open with a warning rather than blocking, so +non-semver pipelines keep working. ##### Output diff --git a/e2e/scenarios/20-promote-allow-downgrade.yaml b/e2e/scenarios/20-promote-allow-downgrade.yaml new file mode 100644 index 0000000..aea6fb7 --- /dev/null +++ b/e2e/scenarios/20-promote-allow-downgrade.yaml @@ -0,0 +1,39 @@ +name: "Promote allow_downgrade dispatch input" +description: | + Verifies that the generated promote.yaml includes the allow_downgrade + workflow_dispatch input and wires it through to the preflight run step. + + Covers: + - allow_downgrade boolean input appears in workflow_dispatch.inputs + - description and default fields are emitted correctly + - ALLOW_DOWNGRADE is bound in the preflight env block + - --allow-downgrade flag is passed to the preflight run command + + Generator-output verification only. + +config: + trunk_branch: main + environments: [dev, test, prod] + deploys: + - name: app + workflow: .github/workflows/deploy.yaml + triggers: ["src/**"] + +steps: + - name: "Initial commit generates promote.yaml; assert allow_downgrade input is wired" + action: commit + commit: + message: "feat: add app source" + files: + src/app.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/promote.yaml" + contains: + - " allow_downgrade:" + - " description: 'Permit promoting an older version (downgrade); prod always requires this'" + - " type: boolean" + - " ALLOW_DOWNGRADE: ${{ github.event.inputs.allow_downgrade }}" + - " --allow-downgrade=\"${ALLOW_DOWNGRADE:-false}\" \\" diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 215a307..6a4a02c 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -584,6 +584,12 @@ func (g *PromoteGenerator) writeWorkflowTriggers(sb *strings.Builder) { sb.WriteString(" type: boolean\n") sb.WriteString(" default: true\n") + // Allow downgrade option + sb.WriteString(" allow_downgrade:\n") + sb.WriteString(" description: 'Permit promoting an older version (downgrade); prod always requires this'\n") + sb.WriteString(" type: boolean\n") + sb.WriteString(" default: false\n") + // Per-deploy checkboxes (kept for backwards compatibility, deprecated) if len(g.config.Deploys) > 0 { sb.WriteString(" # Per-deploy selection (deprecated, use 'deploys' input instead)\n") @@ -673,6 +679,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n") sb.WriteString(" DEPLOYS: ${{ github.event.inputs.deploys }}\n") sb.WriteString(" ROLLBACK_ON_FAILURE: ${{ github.event.inputs.rollback_on_failure }}\n") + sb.WriteString(" ALLOW_DOWNGRADE: ${{ github.event.inputs.allow_downgrade }}\n") for _, d := range g.config.Deploys { fmt.Fprintf(sb, " DEPLOY_%s: ${{ github.event.inputs.deploy_%s }}\n", strings.ToUpper(strings.ReplaceAll(d.Name, "-", "_")), d.Name) @@ -685,6 +692,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" --allow-breaking=\"${ALLOW_BREAKING:-false}\" \\\n") sb.WriteString(" --deploys=\"${DEPLOYS:-all}\" \\\n") sb.WriteString(" --rollback-on-failure=\"${ROLLBACK_ON_FAILURE:-true}\" \\\n") + sb.WriteString(" --allow-downgrade=\"${ALLOW_DOWNGRADE:-false}\" \\\n") sb.WriteString(" --gha-output\n") // Fail if cannot proceed diff --git a/internal/promote/command_preflight.go b/internal/promote/command_preflight.go index 70ed08f..a0fe59d 100644 --- a/internal/promote/command_preflight.go +++ b/internal/promote/command_preflight.go @@ -17,6 +17,7 @@ var ( allowBreaking bool deploysFilter string rollbackOnFailure bool + allowDowngrade bool deployCheckFlags map[string]bool ) @@ -47,6 +48,7 @@ With --gha-output, writes directly to $GITHUB_OUTPUT for use in workflows.`, cmd.Flags().BoolVar(&allowBreaking, "allow-breaking", false, "Allow breaking changes") cmd.Flags().StringVar(&deploysFilter, "deploys", "all", "Deploys to promote (comma-separated names or 'all')") cmd.Flags().BoolVar(&rollbackOnFailure, "rollback-on-failure", true, "Revert successful deploys if any fails") + cmd.Flags().BoolVar(&allowDowngrade, "allow-downgrade", false, "Permit promoting an older version onto an env (downgrade); prod always requires this even if a lower env allowed it") return cmd } @@ -102,6 +104,7 @@ func runPreflight(cmd *cobra.Command, args []string) error { BaseDir: "", DeploysFilter: deploysFilterList, RollbackOnFailure: rollbackOnFailure, + AllowDowngrade: allowDowngrade, }) for name, include := range deployCheckFlags { pf.SetDeployCheck(name, include) diff --git a/internal/promote/downgrade_test.go b/internal/promote/downgrade_test.go new file mode 100644 index 0000000..4f75a3c --- /dev/null +++ b/internal/promote/downgrade_test.go @@ -0,0 +1,160 @@ +package promote + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// newDowngradePreflighter builds a Preflighter wired with the given env state and +// the allow-downgrade flag, so checkDowngrade can be exercised in isolation. +func newDowngradePreflighter(t *testing.T, state map[string]*config.EnvState, allow bool) *Preflighter { + t.Helper() + cfg := &config.CICDFile{ + Config: &config.TrunkConfig{ + Environments: []string{"dev", "test", "uat", "prod"}, + Deploys: []config.DeployConfig{{Name: "app"}}, + }, + State: state, + } + return NewPreflighter(PreflighterOptions{ + Config: cfg, + Mode: ModeDefault, + BaseDir: "", + AllowDowngrade: allow, + }) +} + +func TestCheckDowngrade_ForwardPromotion_Passes(t *testing.T) { + p := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: "v1.0.0"}, + }, false) + result := &PreflightResult{SourceVersion: "v1.1.0"} + promotions := []EnvPromotion{{Environment: "test"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.NoError(t, err) + require.Empty(t, result.Warnings) +} + +func TestCheckDowngrade_EqualVersion_Passes(t *testing.T) { + p := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: "v1.0.0"}, + }, false) + result := &PreflightResult{SourceVersion: "v1.0.0"} + promotions := []EnvPromotion{{Environment: "test"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.NoError(t, err) + require.Empty(t, result.Warnings) +} + +func TestCheckDowngrade_Downgrade_BlockedWithoutFlag(t *testing.T) { + p := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: "v1.2.0"}, + }, false) + result := &PreflightResult{SourceVersion: "v1.1.0"} + promotions := []EnvPromotion{{Environment: "test"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.Error(t, err) + require.Contains(t, err.Error(), "test") + require.Contains(t, err.Error(), "v1.2.0") + require.Contains(t, err.Error(), "v1.1.0") + require.Contains(t, err.Error(), "--allow-downgrade") +} + +func TestCheckDowngrade_Downgrade_AllowedWithFlag(t *testing.T) { + p := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: "v1.2.0"}, + }, true) + result := &PreflightResult{SourceVersion: "v1.1.0"} + promotions := []EnvPromotion{{Environment: "test"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.NoError(t, err) + require.Len(t, result.Warnings, 1) + require.Contains(t, result.Warnings[0], "test") + require.Contains(t, result.Warnings[0], "v1.2.0") + require.Contains(t, result.Warnings[0], "v1.1.0") +} + +func TestCheckDowngrade_ProdDowngrade_AlwaysRequiresFlag(t *testing.T) { + // Without the flag, a prod downgrade is blocked. + p := newDowngradePreflighter(t, map[string]*config.EnvState{ + "prod": {Version: "v2.0.0"}, + }, false) + result := &PreflightResult{SourceVersion: "v1.9.0"} + promotions := []EnvPromotion{{Environment: "prod"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.Error(t, err) + require.Contains(t, err.Error(), "prod") + require.Contains(t, err.Error(), "v2.0.0") + require.Contains(t, err.Error(), "v1.9.0") + require.Contains(t, err.Error(), "--allow-downgrade") + + // With the flag, a prod downgrade is permitted but warned. + pAllow := newDowngradePreflighter(t, map[string]*config.EnvState{ + "prod": {Version: "v2.0.0"}, + }, true) + resultAllow := &PreflightResult{SourceVersion: "v1.9.0"} + err = pAllow.checkDowngrade(promotions, resultAllow, "prod") + require.NoError(t, err) + require.Len(t, resultAllow.Warnings, 1) + require.Contains(t, resultAllow.Warnings[0], "prod") + require.Contains(t, resultAllow.Warnings[0], "v2.0.0") + require.Contains(t, resultAllow.Warnings[0], "v1.9.0") +} + +func TestCheckDowngrade_NonSemver_WarnsAndAllows(t *testing.T) { + // Current version is non-semver: fail-open with a warning naming both + // versions and the env, and proceed. + p := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: "not-a-version"}, + }, false) + result := &PreflightResult{SourceVersion: "v1.1.0"} + promotions := []EnvPromotion{{Environment: "test"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.NoError(t, err) + require.Len(t, result.Warnings, 1) + require.Contains(t, result.Warnings[0], "test") + require.Contains(t, result.Warnings[0], "not-a-version") + require.Contains(t, result.Warnings[0], "v1.1.0") + + // Incoming version is non-semver: also fail-open with a warning. + p2 := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: "v1.1.0"}, + }, false) + result2 := &PreflightResult{SourceVersion: "garbage"} + err = p2.checkDowngrade(promotions, result2, "prod") + require.NoError(t, err) + require.Len(t, result2.Warnings, 1) + require.Contains(t, result2.Warnings[0], "test") + require.Contains(t, result2.Warnings[0], "v1.1.0") + require.Contains(t, result2.Warnings[0], "garbage") +} + +func TestCheckDowngrade_EmptyVersions_Skipped(t *testing.T) { + // Empty current version: skipped, no error, no warning. + p := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: ""}, + }, false) + result := &PreflightResult{SourceVersion: "v1.1.0"} + promotions := []EnvPromotion{{Environment: "test"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.NoError(t, err) + require.Empty(t, result.Warnings) + + // Empty source version: skipped too. + p2 := newDowngradePreflighter(t, map[string]*config.EnvState{ + "test": {Version: "v1.1.0"}, + }, false) + result2 := &PreflightResult{SourceVersion: ""} + err = p2.checkDowngrade(promotions, result2, "prod") + require.NoError(t, err) + require.Empty(t, result2.Warnings) +} diff --git a/internal/promote/preflight.go b/internal/promote/preflight.go index 5ad904e..97318a4 100644 --- a/internal/promote/preflight.go +++ b/internal/promote/preflight.go @@ -10,6 +10,7 @@ import ( "github.com/stablekernel/cascade/internal/changelog" "github.com/stablekernel/cascade/internal/config" "github.com/stablekernel/cascade/internal/git" + "github.com/stablekernel/cascade/internal/version" ) // PreflightResult contains all outputs needed by the workflow @@ -83,6 +84,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 + allowDowngrade bool // Permit promoting an older version onto an env ancestor AncestorFunc // git-ancestry checker for divergence guards } @@ -95,6 +97,7 @@ type PreflighterOptions struct { BaseDir string DeploysFilter []string // Specific deploys to include (empty = all) RollbackOnFailure bool // Revert successful deploys if any fails + AllowDowngrade bool // Permit promoting an older version onto an env } // NewPreflighter creates a new Preflighter instance. Optional behavior (such as @@ -115,6 +118,7 @@ func NewPreflighter(opts PreflighterOptions, options ...Option) *Preflighter { deployChecks: make(map[string]bool), deploysFilter: opts.DeploysFilter, rollbackOnFailure: opts.RollbackOnFailure, + allowDowngrade: opts.AllowDowngrade, ancestor: gc.ancestor, } } @@ -255,9 +259,81 @@ func (p *Preflighter) Run() (*PreflightResult, error) { return nil, err } + // 8. Monotonicity / downgrade gate: promoting an older version onto an env + // regresses it. Block unless --allow-downgrade is set; prod always requires + // the flag even if a lower env permitted the same downgrade. + if err := p.checkDowngrade(promoResult.Promotions, result, prodEnv); err != nil { + return nil, err + } + return result, nil } +// checkDowngrade enforces version monotonicity across the planned promotions. +// For each target env, the incoming SourceVersion must be greater than or equal +// to the env's current version under semver precedence. A strictly older +// incoming version is a downgrade: it is blocked unless allowDowngrade is set, +// in which case a loud warning is recorded and the promotion proceeds. The prod +// env always requires the flag explicitly, even when a lower env in the same +// cascade already permitted the downgrade. +// +// The gate fails open on non-semver inputs: when either the incoming or the +// current version cannot be parsed, it records a warning naming both versions +// and the env, then continues. Envs with an empty incoming or current version +// are skipped silently (first promotion, or version-less manifests). +func (p *Preflighter) checkDowngrade(promotions []EnvPromotion, result *PreflightResult, prodEnv string) error { + for _, promo := range promotions { + state := p.cicdFile.State[promo.Environment] + var currentStr string + if state != nil { + currentStr = state.Version + } + + // Skip if either version is empty (first promotion / version-less). + if result.SourceVersion == "" || currentStr == "" { + continue + } + + incoming, errIn := version.Parse(result.SourceVersion) + current, errCur := version.Parse(currentStr) + if errIn != nil || errCur != nil { + // FAIL-OPEN: non-semver version -> warn and continue. + result.Warnings = append(result.Warnings, fmt.Sprintf( + "downgrade check skipped on env %q: could not compare current version %s to incoming %s; ensure both are semver to enforce monotonicity", + promo.Environment, currentStr, result.SourceVersion, + )) + continue + } + + if incoming.Compare(current) >= 0 { + // Newer or equal: a forward promotion, allow. + continue + } + + // DOWNGRADE: incoming is strictly older than current. + isProd := promo.Environment == prodEnv + if p.allowDowngrade { + result.Warnings = append(result.Warnings, fmt.Sprintf( + "downgrade on %s from %s to %s permitted via --allow-downgrade", + promo.Environment, currentStr, result.SourceVersion, + )) + continue + } + + if isProd { + return fmt.Errorf( + "downgrade blocked on prod env %q: current version %s is newer than incoming %s; prod downgrades always require --allow-downgrade", + promo.Environment, currentStr, result.SourceVersion, + ) + } + return fmt.Errorf( + "downgrade blocked on env %q: current version %s is newer than incoming %s; pass --allow-downgrade to permit", + promo.Environment, currentStr, result.SourceVersion, + ) + } + return 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