Skip to content

Commit 8e7f930

Browse files
feat: add promote downgrade/monotonicity gate (#156)
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent a89d153 commit 8e7f930

7 files changed

Lines changed: 302 additions & 0 deletions

File tree

‎.github/workflows/promote.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ on:
4848
description: 'Revert successful deploys if any fails (atomic promotion)'
4949
type: boolean
5050
default: true
51+
allow_downgrade:
52+
description: 'Permit promoting an older version (downgrade); prod always requires this'
53+
type: boolean
54+
default: false
5155

5256
permissions:
5357
contents: write
@@ -102,6 +106,7 @@ jobs:
102106
ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}
103107
DEPLOYS: ${{ github.event.inputs.deploys }}
104108
ROLLBACK_ON_FAILURE: ${{ github.event.inputs.rollback_on_failure }}
109+
ALLOW_DOWNGRADE: ${{ github.event.inputs.allow_downgrade }}
105110
run: |
106111
cascade promote preflight \
107112
--mode "${PROMOTION_MODE:-default}" \
@@ -110,6 +115,7 @@ jobs:
110115
--allow-breaking="${ALLOW_BREAKING:-false}" \
111116
--deploys="${DEPLOYS:-all}" \
112117
--rollback-on-failure="${ROLLBACK_ON_FAILURE:-true}" \
118+
--allow-downgrade="${ALLOW_DOWNGRADE:-false}" \
113119
--gha-output
114120
- name: Fail if Cannot Proceed
115121
if: steps.preflight.outputs.can_proceed == 'false'

‎docs/src/content/docs/cli-reference.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,16 @@ cascade promote preflight \
331331
| `--allow-breaking` | bool | false | Allow breaking changes past the prerelease boundary |
332332
| `--deploys` | string | `all` | Deploys to promote (comma-separated names or `all`) |
333333
| `--rollback-on-failure` | bool | true | Revert successful deploys if any fails |
334+
| `--allow-downgrade` | bool | false | Permit promoting an older version onto an env (a downgrade). Blocked by default; prod always requires this flag |
335+
336+
A promotion that would place a strictly older semver version onto an env than the
337+
version it currently holds is a downgrade. Preflight blocks it by default, naming
338+
both versions and the env. Pass `--allow-downgrade` to permit it. The terminal
339+
(prod) env always requires the flag, even when a lower env in the same cascade
340+
already permitted the same downgrade. Equal versions are an idempotent
341+
re-promote and are never treated as a downgrade. When either version is not
342+
parseable as semver the gate fails open with a warning rather than blocking, so
343+
non-semver pipelines keep working.
334344

335345
##### Output
336346

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: "Promote allow_downgrade dispatch input"
2+
description: |
3+
Verifies that the generated promote.yaml includes the allow_downgrade
4+
workflow_dispatch input and wires it through to the preflight run step.
5+
6+
Covers:
7+
- allow_downgrade boolean input appears in workflow_dispatch.inputs
8+
- description and default fields are emitted correctly
9+
- ALLOW_DOWNGRADE is bound in the preflight env block
10+
- --allow-downgrade flag is passed to the preflight run command
11+
12+
Generator-output verification only.
13+
14+
config:
15+
trunk_branch: main
16+
environments: [dev, test, prod]
17+
deploys:
18+
- name: app
19+
workflow: .github/workflows/deploy.yaml
20+
triggers: ["src/**"]
21+
22+
steps:
23+
- name: "Initial commit generates promote.yaml; assert allow_downgrade input is wired"
24+
action: commit
25+
commit:
26+
message: "feat: add app source"
27+
files:
28+
src/app.go: |
29+
package main
30+
func main() {}
31+
expect:
32+
workflow_files:
33+
- path: ".github/workflows/promote.yaml"
34+
contains:
35+
- " allow_downgrade:"
36+
- " description: 'Permit promoting an older version (downgrade); prod always requires this'"
37+
- " type: boolean"
38+
- " ALLOW_DOWNGRADE: ${{ github.event.inputs.allow_downgrade }}"
39+
- " --allow-downgrade=\"${ALLOW_DOWNGRADE:-false}\" \\"

‎internal/generate/promote.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,12 @@ func (g *PromoteGenerator) writeWorkflowTriggers(sb *strings.Builder) {
584584
sb.WriteString(" type: boolean\n")
585585
sb.WriteString(" default: true\n")
586586

587+
// Allow downgrade option
588+
sb.WriteString(" allow_downgrade:\n")
589+
sb.WriteString(" description: 'Permit promoting an older version (downgrade); prod always requires this'\n")
590+
sb.WriteString(" type: boolean\n")
591+
sb.WriteString(" default: false\n")
592+
587593
// Per-deploy checkboxes (kept for backwards compatibility, deprecated)
588594
if len(g.config.Deploys) > 0 {
589595
sb.WriteString(" # Per-deploy selection (deprecated, use 'deploys' input instead)\n")
@@ -673,6 +679,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) {
673679
sb.WriteString(" ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n")
674680
sb.WriteString(" DEPLOYS: ${{ github.event.inputs.deploys }}\n")
675681
sb.WriteString(" ROLLBACK_ON_FAILURE: ${{ github.event.inputs.rollback_on_failure }}\n")
682+
sb.WriteString(" ALLOW_DOWNGRADE: ${{ github.event.inputs.allow_downgrade }}\n")
676683
for _, d := range g.config.Deploys {
677684
fmt.Fprintf(sb, " DEPLOY_%s: ${{ github.event.inputs.deploy_%s }}\n",
678685
strings.ToUpper(strings.ReplaceAll(d.Name, "-", "_")), d.Name)
@@ -685,6 +692,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) {
685692
sb.WriteString(" --allow-breaking=\"${ALLOW_BREAKING:-false}\" \\\n")
686693
sb.WriteString(" --deploys=\"${DEPLOYS:-all}\" \\\n")
687694
sb.WriteString(" --rollback-on-failure=\"${ROLLBACK_ON_FAILURE:-true}\" \\\n")
695+
sb.WriteString(" --allow-downgrade=\"${ALLOW_DOWNGRADE:-false}\" \\\n")
688696
sb.WriteString(" --gha-output\n")
689697

690698
// Fail if cannot proceed

‎internal/promote/command_preflight.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ var (
1717
allowBreaking bool
1818
deploysFilter string
1919
rollbackOnFailure bool
20+
allowDowngrade bool
2021
deployCheckFlags map[string]bool
2122
)
2223

@@ -47,6 +48,7 @@ With --gha-output, writes directly to $GITHUB_OUTPUT for use in workflows.`,
4748
cmd.Flags().BoolVar(&allowBreaking, "allow-breaking", false, "Allow breaking changes")
4849
cmd.Flags().StringVar(&deploysFilter, "deploys", "all", "Deploys to promote (comma-separated names or 'all')")
4950
cmd.Flags().BoolVar(&rollbackOnFailure, "rollback-on-failure", true, "Revert successful deploys if any fails")
51+
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")
5052

5153
return cmd
5254
}
@@ -102,6 +104,7 @@ func runPreflight(cmd *cobra.Command, args []string) error {
102104
BaseDir: "",
103105
DeploysFilter: deploysFilterList,
104106
RollbackOnFailure: rollbackOnFailure,
107+
AllowDowngrade: allowDowngrade,
105108
})
106109
for name, include := range deployCheckFlags {
107110
pf.SetDeployCheck(name, include)

‎internal/promote/downgrade_test.go‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package promote
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stablekernel/cascade/internal/config"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// newDowngradePreflighter builds a Preflighter wired with the given env state and
11+
// the allow-downgrade flag, so checkDowngrade can be exercised in isolation.
12+
func newDowngradePreflighter(t *testing.T, state map[string]*config.EnvState, allow bool) *Preflighter {
13+
t.Helper()
14+
cfg := &config.CICDFile{
15+
Config: &config.TrunkConfig{
16+
Environments: []string{"dev", "test", "uat", "prod"},
17+
Deploys: []config.DeployConfig{{Name: "app"}},
18+
},
19+
State: state,
20+
}
21+
return NewPreflighter(PreflighterOptions{
22+
Config: cfg,
23+
Mode: ModeDefault,
24+
BaseDir: "",
25+
AllowDowngrade: allow,
26+
})
27+
}
28+
29+
func TestCheckDowngrade_ForwardPromotion_Passes(t *testing.T) {
30+
p := newDowngradePreflighter(t, map[string]*config.EnvState{
31+
"test": {Version: "v1.0.0"},
32+
}, false)
33+
result := &PreflightResult{SourceVersion: "v1.1.0"}
34+
promotions := []EnvPromotion{{Environment: "test"}}
35+
36+
err := p.checkDowngrade(promotions, result, "prod")
37+
require.NoError(t, err)
38+
require.Empty(t, result.Warnings)
39+
}
40+
41+
func TestCheckDowngrade_EqualVersion_Passes(t *testing.T) {
42+
p := newDowngradePreflighter(t, map[string]*config.EnvState{
43+
"test": {Version: "v1.0.0"},
44+
}, false)
45+
result := &PreflightResult{SourceVersion: "v1.0.0"}
46+
promotions := []EnvPromotion{{Environment: "test"}}
47+
48+
err := p.checkDowngrade(promotions, result, "prod")
49+
require.NoError(t, err)
50+
require.Empty(t, result.Warnings)
51+
}
52+
53+
func TestCheckDowngrade_Downgrade_BlockedWithoutFlag(t *testing.T) {
54+
p := newDowngradePreflighter(t, map[string]*config.EnvState{
55+
"test": {Version: "v1.2.0"},
56+
}, false)
57+
result := &PreflightResult{SourceVersion: "v1.1.0"}
58+
promotions := []EnvPromotion{{Environment: "test"}}
59+
60+
err := p.checkDowngrade(promotions, result, "prod")
61+
require.Error(t, err)
62+
require.Contains(t, err.Error(), "test")
63+
require.Contains(t, err.Error(), "v1.2.0")
64+
require.Contains(t, err.Error(), "v1.1.0")
65+
require.Contains(t, err.Error(), "--allow-downgrade")
66+
}
67+
68+
func TestCheckDowngrade_Downgrade_AllowedWithFlag(t *testing.T) {
69+
p := newDowngradePreflighter(t, map[string]*config.EnvState{
70+
"test": {Version: "v1.2.0"},
71+
}, true)
72+
result := &PreflightResult{SourceVersion: "v1.1.0"}
73+
promotions := []EnvPromotion{{Environment: "test"}}
74+
75+
err := p.checkDowngrade(promotions, result, "prod")
76+
require.NoError(t, err)
77+
require.Len(t, result.Warnings, 1)
78+
require.Contains(t, result.Warnings[0], "test")
79+
require.Contains(t, result.Warnings[0], "v1.2.0")
80+
require.Contains(t, result.Warnings[0], "v1.1.0")
81+
}
82+
83+
func TestCheckDowngrade_ProdDowngrade_AlwaysRequiresFlag(t *testing.T) {
84+
// Without the flag, a prod downgrade is blocked.
85+
p := newDowngradePreflighter(t, map[string]*config.EnvState{
86+
"prod": {Version: "v2.0.0"},
87+
}, false)
88+
result := &PreflightResult{SourceVersion: "v1.9.0"}
89+
promotions := []EnvPromotion{{Environment: "prod"}}
90+
91+
err := p.checkDowngrade(promotions, result, "prod")
92+
require.Error(t, err)
93+
require.Contains(t, err.Error(), "prod")
94+
require.Contains(t, err.Error(), "v2.0.0")
95+
require.Contains(t, err.Error(), "v1.9.0")
96+
require.Contains(t, err.Error(), "--allow-downgrade")
97+
98+
// With the flag, a prod downgrade is permitted but warned.
99+
pAllow := newDowngradePreflighter(t, map[string]*config.EnvState{
100+
"prod": {Version: "v2.0.0"},
101+
}, true)
102+
resultAllow := &PreflightResult{SourceVersion: "v1.9.0"}
103+
err = pAllow.checkDowngrade(promotions, resultAllow, "prod")
104+
require.NoError(t, err)
105+
require.Len(t, resultAllow.Warnings, 1)
106+
require.Contains(t, resultAllow.Warnings[0], "prod")
107+
require.Contains(t, resultAllow.Warnings[0], "v2.0.0")
108+
require.Contains(t, resultAllow.Warnings[0], "v1.9.0")
109+
}
110+
111+
func TestCheckDowngrade_NonSemver_WarnsAndAllows(t *testing.T) {
112+
// Current version is non-semver: fail-open with a warning naming both
113+
// versions and the env, and proceed.
114+
p := newDowngradePreflighter(t, map[string]*config.EnvState{
115+
"test": {Version: "not-a-version"},
116+
}, false)
117+
result := &PreflightResult{SourceVersion: "v1.1.0"}
118+
promotions := []EnvPromotion{{Environment: "test"}}
119+
120+
err := p.checkDowngrade(promotions, result, "prod")
121+
require.NoError(t, err)
122+
require.Len(t, result.Warnings, 1)
123+
require.Contains(t, result.Warnings[0], "test")
124+
require.Contains(t, result.Warnings[0], "not-a-version")
125+
require.Contains(t, result.Warnings[0], "v1.1.0")
126+
127+
// Incoming version is non-semver: also fail-open with a warning.
128+
p2 := newDowngradePreflighter(t, map[string]*config.EnvState{
129+
"test": {Version: "v1.1.0"},
130+
}, false)
131+
result2 := &PreflightResult{SourceVersion: "garbage"}
132+
err = p2.checkDowngrade(promotions, result2, "prod")
133+
require.NoError(t, err)
134+
require.Len(t, result2.Warnings, 1)
135+
require.Contains(t, result2.Warnings[0], "test")
136+
require.Contains(t, result2.Warnings[0], "v1.1.0")
137+
require.Contains(t, result2.Warnings[0], "garbage")
138+
}
139+
140+
func TestCheckDowngrade_EmptyVersions_Skipped(t *testing.T) {
141+
// Empty current version: skipped, no error, no warning.
142+
p := newDowngradePreflighter(t, map[string]*config.EnvState{
143+
"test": {Version: ""},
144+
}, false)
145+
result := &PreflightResult{SourceVersion: "v1.1.0"}
146+
promotions := []EnvPromotion{{Environment: "test"}}
147+
148+
err := p.checkDowngrade(promotions, result, "prod")
149+
require.NoError(t, err)
150+
require.Empty(t, result.Warnings)
151+
152+
// Empty source version: skipped too.
153+
p2 := newDowngradePreflighter(t, map[string]*config.EnvState{
154+
"test": {Version: "v1.1.0"},
155+
}, false)
156+
result2 := &PreflightResult{SourceVersion: ""}
157+
err = p2.checkDowngrade(promotions, result2, "prod")
158+
require.NoError(t, err)
159+
require.Empty(t, result2.Warnings)
160+
}

0 commit comments

Comments
 (0)