Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/promote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}" \
Expand All @@ -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'
Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions e2e/scenarios/20-promote-allow-downgrade.yaml
Original file line number Diff line number Diff line change
@@ -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}\" \\"
8 changes: 8 additions & 0 deletions internal/generate/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/promote/command_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var (
allowBreaking bool
deploysFilter string
rollbackOnFailure bool
allowDowngrade bool
deployCheckFlags map[string]bool
)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
160 changes: 160 additions & 0 deletions internal/promote/downgrade_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading