diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 0251838e..ecb60dc6 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -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 }})." diff --git a/docs/src/content/docs/guides/promote.md b/docs/src/content/docs/guides/promote.md index fc203cec..2cee4eed 100644 --- a/docs/src/content/docs/guides/promote.md +++ b/docs/src/content/docs/guides/promote.md @@ -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. | | `-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 | |-------|------|---------|---------| diff --git a/docs/src/content/docs/internals/coverage-matrix.md b/docs/src/content/docs/internals/coverage-matrix.md index 0a3912fa..60106139 100644 --- a/docs/src/content/docs/internals/coverage-matrix.md +++ b/docs/src/content/docs/internals/coverage-matrix.md @@ -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 | diff --git a/docs/src/content/docs/reference/manifest.md b/docs/src/content/docs/reference/manifest.md index b9eacba5..7a29648b 100644 --- a/docs/src/content/docs/reference/manifest.md +++ b/docs/src/content/docs/reference/manifest.md @@ -572,6 +572,28 @@ When `workflow` is set, cascade dispatches it (via `gh workflow run --ref ` 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. diff --git a/e2e/scenarios/49-allow-breaking-changes-manifest.yaml b/e2e/scenarios/49-allow-breaking-changes-manifest.yaml new file mode 100644 index 00000000..949cf0c8 --- /dev/null +++ b/e2e/scenarios/49-allow-breaking-changes-manifest.yaml @@ -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" diff --git a/internal/config/types.go b/internal/config/types.go index b72d73fd..ec3cf4c9 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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 @@ -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", diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 495ff75c..24cacbf0 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -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") +} diff --git a/internal/generate/release.go b/internal/generate/release.go index d943f902..33a625eb 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -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") diff --git a/internal/generate/release_test.go b/internal/generate/release_test.go index 349d8ccf..be6f31c5 100644 --- a/internal/generate/release_test.go +++ b/internal/generate/release_test.go @@ -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") +} diff --git a/internal/promote/breaking_gate_test.go b/internal/promote/breaking_gate_test.go new file mode 100644 index 00000000..a259458a --- /dev/null +++ b/internal/promote/breaking_gate_test.go @@ -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) + }) +} diff --git a/internal/promote/preflight.go b/internal/promote/preflight.go index 114edbd3..c9bb543a 100644 --- a/internal/promote/preflight.go +++ b/internal/promote/preflight.go @@ -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 == "" { diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 0251838e..ecb60dc6 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -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 }})." diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index 35f27865..12afbf7c 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -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") + } +} diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 0251838e..ecb60dc6 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -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 }})."