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
4 changes: 4 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
"description": "Version tag prefix (default: \"v\")."
},
"tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" },
"allow_breaking_changes": {
"type": "boolean",
"description": "Disable the breaking-change promote gate for this repository. Default false keeps the gate enabled, so a feat!: or BREAKING CHANGE: commit blocks the pre-release to release and release to prod crossings. Set true to let those crossings proceed without the per-run override."
},
"release_token": {
"type": "string",
"description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})."
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/promote.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ With no other inputs, `mode=default` advances the chain by exactly one step: the
| `default` | Advance one logical step from the environment currently ahead. |
| `<from>-to-<to>` (for example `dev-to-prod`) | Cascade through every environment between `from` and `to`, deploying and finalizing each one in turn. |

A breaking-change gate sits at the prerelease-to-release boundary regardless of mode. If the commits being promoted include a breaking change, the run stops there unless you pass `allow_breaking_changes: true`.
A breaking-change gate sits at the prerelease-to-release boundary regardless of mode. If the commits being promoted include a breaking change, the run stops there unless you pass `allow_breaking_changes: true`. A repository can turn the gate off for good by setting [`allow_breaking_changes: true`](/cascade/reference/manifest/#allow_breaking_changes) in the manifest config, so the per-run input is not needed.

| Input | Type | Default | Purpose |
|-------|------|---------|---------|
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/internals/coverage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ only under real installation tokens on the fleet, never in the token-free harnes
| No-change skip | `06-no-change-skip` | `probe_concurrency` step 12 (4env) | `internal/changes` | A re-dispatch with no watched change skips, on real Actions |
| Concurrency cancellation | `07-orchestrate-concurrency` | `probe_concurrency` cancel (4env) | `internal/generate` | The older run in a per-component group is cancelled and the survivor concludes |
| Breaking-change gate | `37-release-breaking-gate`, `38-promote-breaking-gate-release-build` | `release-gates` (primary) | `internal/promote`, `internal/release` | A breaking transition is refused without the explicit allow flag |
| Breaking-change gate disabled per-repo | `49-allow-breaking-changes-manifest` | | `internal/config`, `internal/promote`, `internal/generate` | A manifest `allow_breaking_changes: true` bakes the gate off, so a breaking release proceeds without the per-run override |
| Promote from diverged env blocked | `rollback/rollback-marks-diverged-blocks-promote` | `rollback-check` diverged-blocks-promote (2env) | `internal/promote` | Promotion from a diverged source is refused (registered as an expected failure) |
| Allow-downgrade and prod guard | `20-promote-allow-downgrade` | `release-gates` (primary) | `internal/promote/downgrade.go` | A downgrade needs the flag, and prod needs it even when a lower env does not |
| Promote with missing source | `promote/promote-fails-missing-source` | | `internal/promote` | A promotion with no built source fails fast |
Expand Down
22 changes: 22 additions & 0 deletions docs/src/content/docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,28 @@ When `workflow` is set, cascade dispatches it (via `gh workflow run --ref <tag>`

Omit the section to use the built-in conventional commit parser.

## allow_breaking_changes

| Field | Status | Type | Default | Description |
|-------|--------|------|---------|-------------|
| `allow_breaking_changes` | emitted | bool | false | Disable the breaking-change gate for this repository. |

By default the gate is enabled: a `feat!:` or `BREAKING CHANGE:` commit blocks the
pre-release to release boundary (and release to prod) during a promote or release, so the
major bump is a deliberate act. Set `allow_breaking_changes: true` at the config level to
turn the gate off once for the whole repository. Those crossings then proceed without the
per-run `allow_breaking_changes` workflow input, which stays available for repositories that
leave the gate on.

```yaml
ci:
config:
allow_breaking_changes: true
```

Leave it unset (the default) to keep the gate on. A manifest that omits the field generates
byte-identical workflows to before.

## environment_config

Per-environment settings keyed by environment name. Consumed for native GitHub Environment support and deployment URLs.
Expand Down
49 changes: 49 additions & 0 deletions e2e/scenarios/49-allow-breaking-changes-manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: "Manifest allow_breaking_changes bakes the gate off"
description: |
Verifies that setting allow_breaking_changes: true at the config level bakes
the release breaking-change gate off, so a breaking release proceeds without
the per-run allow_breaking_changes input (#492).

A single-environment manifest generates a Release-flavored workflow into
promote.yaml (see 09-single-env-repo.yaml and 37-release-breaking-gate.yaml).
With the gate enabled (the default) the check reads the per-run input; with
allow_breaking_changes: true the generator bakes ALLOW_BREAKING to "true".

The operator-facing allow_breaking_changes input stays declared; the gate
simply no longer depends on it. The gate itself is skipped for the whole
repository, so a feat!: commit crosses the pre-release to release boundary
without a per-run override.

Generator-output verification only.

config:
trunk_branch: main
environments: [prod]
allow_breaking_changes: true
deploys:
- name: app
workflow: .github/workflows/deploy.yaml
triggers: ["src/**"]

steps:
- name: "Initial commit generates promote.yaml; assert the gate is baked off"
action: commit
commit:
message: "feat!: drop legacy API"
files:
src/app.go: |
package main
func main() {}
expect:
workflow_files:
- path: ".github/workflows/promote.yaml"
contains:
# The detection step still runs, but the allow decision is baked on.
- " - name: Check Breaking Changes\n"
- " ALLOW_BREAKING: \"true\"\n"
- " echo \"has_breaking=$HAS_BREAKING\" >> \"$GITHUB_OUTPUT\"\n"
# The operator-facing input remains declared for gate-on repos.
- " allow_breaking_changes:\n"
not_contains:
# The gate no longer reads the per-run input.
- " ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n"
13 changes: 13 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ type TrunkConfig struct {
// token and separator, dryrun token). Absent by default; when absent the
// historical grammar is used. Additive and scoped to tag shape only.
TagGrammar *TagGrammarConfig `yaml:"tag_grammar,omitempty" json:"tag_grammar,omitempty"`
// AllowBreakingChanges disables the breaking-change promote gate for this
// repository. The zero value (false, the default) keeps the gate enabled, so
// a feat!: or BREAKING CHANGE: commit still blocks the pre-release to release
// and release to prod crossings. Set true to let those crossings proceed
// without the per-run override. Additive and opt-in.
AllowBreakingChanges bool `yaml:"allow_breaking_changes,omitempty" json:"allow_breaking_changes,omitempty"`
ReleaseToken string `yaml:"release_token,omitempty" json:"release_token,omitempty"` // GitHub secret name for release operations (default: "GITHUB_TOKEN")
StateToken string `yaml:"state_token,omitempty" json:"state_token,omitempty"` // Token expression for writing manifest state to the trunk branch (default: "GITHUB_TOKEN")
// ReleaseTokenApp optionally backs the release-token seam with a GitHub App
Expand Down Expand Up @@ -319,6 +325,13 @@ func (c *TrunkConfig) GetTagPrefix() string {
return c.TagPrefix
}

// AllowsBreakingChanges reports whether the breaking-change promote gate is
// disabled for this repository via the manifest. Nil-safe: a nil config keeps
// the gate enabled (returns false).
func (c *TrunkConfig) AllowsBreakingChanges() bool {
return c != nil && c.AllowBreakingChanges
}

// normalizeTokenExpression returns a GitHub Actions expression that resolves to
// a token at run time. It accepts either a full expression
// ("${{ secrets.MY_TOKEN }}"), an unwrapped context form ("secrets.MY_TOKEN",
Expand Down
39 changes: 39 additions & 0 deletions internal/config/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1141,3 +1141,42 @@ environments:
t.Fatalf("re-marshal changed output:\nwant:\n%s\ngot:\n%s", src, out)
}
}

func TestTrunkConfig_AllowsBreakingChanges(t *testing.T) {
tests := []struct {
name string
cfg *TrunkConfig
want bool
}{
{name: "nil config keeps gate enabled", cfg: nil, want: false},
{name: "unset field keeps gate enabled", cfg: &TrunkConfig{}, want: false},
{name: "explicit false keeps gate enabled", cfg: &TrunkConfig{AllowBreakingChanges: false}, want: false},
{name: "true disables gate", cfg: &TrunkConfig{AllowBreakingChanges: true}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.cfg.AllowsBreakingChanges())
})
}
}

func TestTrunkConfig_AllowBreakingChanges_YAMLRoundTrip(t *testing.T) {
const src = `trunk_branch: main
allow_breaking_changes: true
`
var cfg TrunkConfig
require.NoError(t, yaml.Unmarshal([]byte(src), &cfg))
assert.True(t, cfg.AllowBreakingChanges)

out, err := yaml.Marshal(&cfg)
require.NoError(t, err)
assert.Equal(t, src, string(out))

// Zero value stays absent from emitted YAML (omitempty), so default
// manifests are byte-identical to today.
var zero TrunkConfig
require.NoError(t, yaml.Unmarshal([]byte("trunk_branch: main\n"), &zero))
zeroOut, err := yaml.Marshal(&zero)
require.NoError(t, err)
assert.NotContains(t, string(zeroOut), "allow_breaking_changes")
}
10 changes: 9 additions & 1 deletion internal/generate/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,15 @@ func (g *ReleaseGenerator) writePreflightJob(sb *strings.Builder) {
sb.WriteString(" id: check\n")
sb.WriteString(" env:\n")
sb.WriteString(" SOURCE_SHA: ${{ steps.validate.outputs.source_sha }}\n")
sb.WriteString(" ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n")
// The gate reads the per-run workflow input by default. A repo that opts out
// with allow_breaking_changes: true bakes the value on at generation time, so
// a breaking release proceeds even when the operator leaves the input
// unchecked. Mirrors how the tag grammar is baked from g.config.
if g.config.AllowsBreakingChanges() {
sb.WriteString(" ALLOW_BREAKING: \"true\"\n")
} else {
sb.WriteString(" ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n")
}
sb.WriteString(" run: |\n")
sb.WriteString(" # Colorized logging helpers\n")
sb.WriteString(" log_info() { echo -e \"\\033[36m[INFO]\\033[0m $1\"; }\n")
Expand Down
20 changes: 20 additions & 0 deletions internal/generate/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,23 @@ func TestReleaseGenerator_ConcurrencyOverride(t *testing.T) {
assert.Contains(t, content, "group: my-custom-release", "custom group must propagate to release")
assert.Contains(t, content, "cancel-in-progress: true", "custom cancel_in_progress must propagate to release")
}

func TestReleaseGenerator_AllowBreakingChanges_BakesGateOff(t *testing.T) {
// Default: the breaking-change gate reads the per-run workflow input.
def := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}}
defContent, err := NewReleaseGenerator(def, "").Generate()
require.NoError(t, err)
assert.Contains(t, defContent, "ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n")
assert.NotContains(t, defContent, "ALLOW_BREAKING: \"true\"\n")

// allow_breaking_changes: true bakes the gate off so a breaking release
// proceeds even when the per-run input is unchecked.
on := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"prod"}, AllowBreakingChanges: true}
onContent, err := NewReleaseGenerator(on, "").Generate()
require.NoError(t, err)
assert.Contains(t, onContent, "ALLOW_BREAKING: \"true\"\n")
assert.NotContains(t, onContent, "ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }}\n")
// The operator-facing input stays declared; the gate simply no longer
// depends on it.
assert.Contains(t, onContent, "allow_breaking_changes:\n")
}
71 changes: 71 additions & 0 deletions internal/promote/breaking_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package promote

import (
"os"
"path/filepath"
"testing"

"github.com/stablekernel/cascade/internal/config"
"github.com/stretchr/testify/require"
)

// TestCheckBreakingChangesForMode_ManifestGate proves the manifest field
// allow_breaking_changes disables the breaking-change gate for the whole
// repository. With the field unset (the default) a feat!: commit crossing the
// pre-release to release boundary still blocks; with the field set the same
// crossing proceeds, matching the per-run --allow-breaking override applied
// once for the repo.
func TestCheckBreakingChangesForMode_ManifestGate(t *testing.T) {
// Real git repo: a base commit, then a breaking commit as HEAD. The env
// state SHA points at the base so the range (base, head] carries the
// breaking commit.
dir := t.TempDir()
runGit(t, dir, "init", "-b", "main")
runGit(t, dir, "config", "user.email", "test@example.com")
runGit(t, dir, "config", "user.name", "Test User")
runGit(t, dir, "config", "commit.gpgsign", "false")

require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one"), 0o644))
runGit(t, dir, "add", "a.txt")
runGit(t, dir, "commit", "-m", "chore: base")
baseSHA := gitOut(t, dir, "rev-parse", "HEAD")

require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("two"), 0o644))
runGit(t, dir, "add", "a.txt")
runGit(t, dir, "commit", "-m", "feat!: drop legacy API")
headSHA := gitOut(t, dir, "rev-parse", "HEAD")

// git.GetCommits runs in the process working directory.
t.Chdir(dir)

newPreflighter := func(allow bool) *Preflighter {
return NewPreflighter(PreflighterOptions{
Config: &config.CICDFile{
Config: &config.TrunkConfig{
Environments: []string{"staging"},
AllowBreakingChanges: allow,
},
State: map[string]*config.EnvState{
"release": {SHA: baseSHA},
},
},
Mode: ModeDefault,
})
}

promotions := []EnvPromotion{{Environment: "release", SHA: headSHA}}

t.Run("gate enabled by default blocks a breaking crossing", func(t *testing.T) {
pf := newPreflighter(false)
hasBreaking, blockedAt := pf.checkBreakingChangesForMode(promotions, "staging", "prod", true)
require.True(t, hasBreaking, "a feat!: commit must block the pre-release to release crossing")
require.Equal(t, "staging → release", blockedAt)
})

t.Run("manifest field disables the gate", func(t *testing.T) {
pf := newPreflighter(true)
hasBreaking, blockedAt := pf.checkBreakingChangesForMode(promotions, "staging", "prod", true)
require.False(t, hasBreaking, "allow_breaking_changes must let the crossing proceed")
require.Empty(t, blockedAt)
})
}
7 changes: 7 additions & 0 deletions internal/promote/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,13 @@ func (p *Preflighter) checkBreakingChangesForMode(
return false, ""
}

// A repository may disable the gate for good via the manifest
// (allow_breaking_changes: true). When set, no crossing blocks, mirroring
// the per-run --allow-breaking override but applied once for the repo.
if p.cicdFile.Config.AllowsBreakingChanges() {
return false, ""
}

// Get source SHA for breaking change detection
sourceSHA := promotions[0].SHA
if sourceSHA == "" {
Expand Down
4 changes: 4 additions & 0 deletions internal/schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
"description": "Version tag prefix (default: \"v\")."
},
"tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" },
"allow_breaking_changes": {
"type": "boolean",
"description": "Disable the breaking-change promote gate for this repository. Default false keeps the gate enabled, so a feat!: or BREAKING CHANGE: commit blocks the pre-release to release and release to prod crossings. Set true to let those crossings proceed without the per-run override."
},
"release_token": {
"type": "string",
"description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})."
Expand Down
28 changes: 28 additions & 0 deletions internal/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,31 @@ func TestSchema_OnDiskCopiesAreByteIdentical(t *testing.T) {
}
}
}

// TestSchema_AcceptsAllowBreakingChanges proves the config-level
// allow_breaking_changes boolean is declared, so a manifest that disables the
// breaking-change gate validates under the config object's
// additionalProperties: false.
func TestSchema_AcceptsAllowBreakingChanges(t *testing.T) {
sch := compileSchema(t)

good := map[string]any{
"ci": map[string]any{"config": map[string]any{
"trunk_branch": "main",
"allow_breaking_changes": true,
}},
}
if err := sch.Validate(toJSONValue(t, good)); err != nil {
t.Fatalf("allow_breaking_changes must validate: %v", err)
}

bad := map[string]any{
"ci": map[string]any{"config": map[string]any{
"trunk_branch": "main",
"allow_breaking_changes": "yes",
}},
}
if err := sch.Validate(toJSONValue(t, bad)); err == nil {
t.Fatalf("a non-boolean allow_breaking_changes must be rejected")
}
}
4 changes: 4 additions & 0 deletions schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
"description": "Version tag prefix (default: \"v\")."
},
"tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" },
"allow_breaking_changes": {
"type": "boolean",
"description": "Disable the breaking-change promote gate for this repository. Default false keeps the gate enabled, so a feat!: or BREAKING CHANGE: commit blocks the pre-release to release and release to prod crossings. Set true to let those crossings proceed without the per-run override."
},
"release_token": {
"type": "string",
"description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})."
Expand Down