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
11 changes: 10 additions & 1 deletion docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,16 @@
"description": "Release management settings.",
"properties": {
"disabled": { "type": "boolean", "description": "Disable cascade-managed releases." },
"tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." }
"tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." },
"version_overrides": { "$ref": "#/definitions/versionOverridesConfig" }
}
},
"versionOverridesConfig": {
"type": "object",
"additionalProperties": false,
"description": "Reserved pointer to the version-intent override-file location. Reserved shape only: parse and structural validation, no generator behavior.",
"properties": {
"dir": { "type": "string", "description": "Directory holding override files. Relative, no '..' segments. Empty means the implementation default (reserved)." }
}
},
"changelogConfig": {
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ ci:
|-------|------|---------|-------------|
| `disabled` | bool | false | Disable framework release management |
| `tag` | string | - | callback.output reference for an external release tool |
| `version_overrides` | object | - | Reserved pointer (`dir:`) to maintainer-committed version-intent override files. Reserved shape only; see [Versioning](/versioning/#reserved-shape-version-intent-overrides). |

Omit this section to use framework defaults (creates releases with conventional commit changelogs).

Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ The manifest reserves a vendor-neutral telemetry seam under `config.telemetry`.

These fields parse and pass structural validation today, but carry no generator or emit behavior. A manifest declaring them produces byte-identical generated workflows, so the reserved shape is safe to adopt now. Attaching behavior to these fields later is additive and does not bump `schema_version`.

## Reserved shape: version-intent overrides

cascade derives the next version from conventional commits. Some version intent cannot be expressed that way, for example forcing a pre-release line or a specific exact version for a release. The manifest reserves, under `release:`, a `version_overrides:` block that addresses maintainer-committed override files carrying that intent:

- `dir`, a relative directory pointer to the override files. It must be a relative path with no `..` segments. Empty means the implementation default (reserved).

Only the addressing pointer is frozen in v1. The override-file format and the fold-into-version-calculation behavior are additive and arrive post-1.0; any future override values map onto the existing version primitives (the bump level and the pre-release line) rather than introducing a parallel scheme. This block parses and passes structural validation today, but carries no generator, state, or runtime behavior. A manifest declaring it produces byte-identical generated workflows, so the reserved shape is safe to adopt now, and attaching behavior later does not bump `schema_version`.

## Migrations

Each `schema_version` bump is recorded with a `Migration` section in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md) describing exactly what changed and the steps to update a manifest from the previous version. There are no migrations yet: the current schema version is the first.
Expand Down
6 changes: 6 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ type Config struct {
// TelemetryConfig shape, so a scenario can declare any reserved telemetry
// field without the harness needing to know its structure.
Telemetry map[string]any `yaml:"telemetry,omitempty"`
// Release carries the release block (disabled, tag, version_overrides)
// through to the generated manifest untouched. A generic map keeps the
// harness decoupled from the generator's ReleaseConfig shape, so a scenario
// can declare any reserved release field without the harness needing to know
// its structure.
Release map[string]any `yaml:"release,omitempty"`
}

// EnvEnvironmentConfig mirrors internal/config.EnvironmentConfig's gha_environment
Expand Down
42 changes: 42 additions & 0 deletions e2e/scenarios/26-version-overrides-reserved.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: "Version Overrides Reserved Shape"
description: |
Exercises the reserved version-intent override pointer
(config.release.version_overrides) with its reserved dir field. This block is
reserved and shape-only today: it parses and passes structural validation, but
carries no generator, state, or runtime behavior. dir is a relative pointer to
maintainer-committed override files; no override files are present in this
scenario. The scenario declares the version_overrides shape, generates the
workflows, then regenerates and proves the output is byte-identical with no
drift.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["src/**"]
release:
version_overrides:
dir: .cascade/version-overrides

steps:
- name: "Seed a minimal source tree"
action: commit
commit:
message: "seed source"
files:
src/main.go: |
package main

func main() {}

- name: "Regenerate and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0
1 change: 1 addition & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ func Validate(cfg *TrunkConfig) []string {
// Config-level structural validation for v1 reserved fields.
errors = append(errors, validateConfigLevel(cfg)...)
errors = append(errors, validateComponents(cfg)...)
errors = append(errors, validateVersionOverrides(cfg.Release)...)

// Validate release.tag reference
if cfg.Release != nil && cfg.Release.Tag != "" {
Expand Down
17 changes: 17 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,23 @@ type ReleaseConfig struct {
// unset, no release-build dispatch is emitted and publishing a release does
// not trigger any follow-on workflow.
Workflow string `yaml:"workflow,omitempty" json:"workflow,omitempty"`

// VersionOverrides reserves the addressing pointer for maintainer-committed
// version-intent override files. RESERVED - parse + structural validation
// only; no generator/state/runtime behavior. Absent by default.
VersionOverrides *VersionOverridesConfig `yaml:"version_overrides,omitempty" json:"version_overrides,omitempty"`
}

// VersionOverridesConfig is the reserved pointer to the override-file location.
// Only the addressing shape is frozen in v1; file format and the
// fold-into-version-calculation behavior land post-1.0 additively. Any future
// override values map onto the canonical version primitives in
// internal/version (BumpType: BumpNone/BumpPatch/BumpMinor/BumpMajor and the
// PreRelease line); this reservation introduces no parallel enum.
type VersionOverridesConfig struct {
// Dir is the directory holding override files. Relative, no ".." segments.
// Empty => the implementation default (reserved).
Dir string `yaml:"dir,omitempty" json:"dir,omitempty"`
}

// ExternalRepoConfig defines an external repository that this primary coordinates
Expand Down
22 changes: 22 additions & 0 deletions internal/config/validate_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,28 @@ func validateComponents(cfg *TrunkConfig) []string {
return errs
}

// validateVersionOverrides validates the reserved release.version_overrides
// pointer. Rules frozen at v1 mirror components.path: any configured dir must be
// a clean relative path (no leading slash, no ".." segments). Validation applies
// only when the block is present, so it never rejects a manifest that is valid
// without it.
func validateVersionOverrides(release *ReleaseConfig) []string {
if release == nil || release.VersionOverrides == nil {
return nil
}
dir := release.VersionOverrides.Dir
if dir == "" {
return nil
}
var errs []string
if strings.HasPrefix(dir, "/") {
errs = append(errs, "release.version_overrides.dir must be a relative path, not absolute")
} else if strings.Contains(dir, "..") {
errs = append(errs, "release.version_overrides.dir must not contain '..' segments")
}
return errs
}

// sortedComponentKeys returns the keys of a ComponentConfig map in deterministic order.
func sortedComponentKeys(m map[string]ComponentConfig) []string {
keys := make([]string, 0, len(m))
Expand Down
131 changes: 131 additions & 0 deletions internal/config/validate_versionoverrides_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package config

import "testing"

// TestValidateVersionOverridesReservedField exercises the reserved
// release.version_overrides.dir pointer. Validation applies only when the block
// is present, mirrors the components.path rules (no absolute path, no ".."
// segments), and never rejects a manifest that is valid without the block.
func TestValidateVersionOverridesReservedField(t *testing.T) {
t.Parallel()

tests := []struct {
name string
release *ReleaseConfig
wantErr bool
errContains string
}{
{
name: "nil release is valid",
release: nil,
wantErr: false,
},
{
name: "release without version_overrides is valid",
release: &ReleaseConfig{},
wantErr: false,
},
{
name: "version_overrides with empty dir is valid",
release: &ReleaseConfig{VersionOverrides: &VersionOverridesConfig{}},
wantErr: false,
},
{
name: "version_overrides with a clean relative dir is valid",
release: &ReleaseConfig{VersionOverrides: &VersionOverridesConfig{Dir: ".cascade/overrides"}},
wantErr: false,
},
{
name: "absolute dir is rejected",
release: &ReleaseConfig{VersionOverrides: &VersionOverridesConfig{Dir: "/etc/overrides"}},
wantErr: true,
errContains: "release.version_overrides.dir must be a relative path, not absolute",
},
{
name: "dir with parent segment is rejected",
release: &ReleaseConfig{VersionOverrides: &VersionOverridesConfig{Dir: "../escape"}},
wantErr: true,
errContains: "release.version_overrides.dir must not contain '..' segments",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
errs := validateVersionOverrides(tt.release)
if tt.wantErr {
if len(errs) == 0 {
t.Fatalf("expected an error, got none")
}
if !hasErrContaining(errs, tt.errContains) {
t.Fatalf("expected error containing %q, got %v", tt.errContains, errs)
}
return
}
if len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
})
}
}

// TestParseVersionOverridesReservedField asserts a manifest carrying the reserved
// release.version_overrides block parses into the typed field, validates at
// CurrentSchemaVersion, and does not bump schema_version.
func TestParseVersionOverridesReservedField(t *testing.T) {
t.Parallel()

cfg := parseInline(t, `
environments: [dev, prod]
deploys:
- name: app
workflow: .github/workflows/deploy.yaml
release:
version_overrides:
dir: .cascade/version-overrides
`)
if cfg.Release == nil {
t.Fatalf("release block did not parse")
}
if cfg.Release.VersionOverrides == nil {
t.Fatalf("release.version_overrides did not parse")
}
if got := cfg.Release.VersionOverrides.Dir; got != ".cascade/version-overrides" {
t.Fatalf("release.version_overrides.dir = %q", got)
}
if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
if got := cfg.GetSchemaVersion(); got != CurrentSchemaVersion {
t.Fatalf("schema_version = %d, want %d (reserved shape must not bump)", got, CurrentSchemaVersion)
}
if CurrentSchemaVersion != 1 {
t.Fatalf("CurrentSchemaVersion = %d, want 1 (reserved shape must not bump)", CurrentSchemaVersion)
}
}

// TestParseVersionOverridesAbsentStaysValid confirms a manifest with a release
// block but no version_overrides validates clean: the reservation never adds an
// error to a manifest that does not opt in.
func TestParseVersionOverridesAbsentStaysValid(t *testing.T) {
t.Parallel()

cfg := parseInline(t, `
environments: [dev, prod]
deploys:
- name: app
workflow: .github/workflows/deploy.yaml
release:
disabled: false
`)
if cfg.Release == nil {
t.Fatalf("release block did not parse")
}
if cfg.Release.VersionOverrides != nil {
t.Fatalf("version_overrides should be nil when absent, got %#v", cfg.Release.VersionOverrides)
}
if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
63 changes: 63 additions & 0 deletions internal/generate/versionoverrides_reserved_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package generate

import (
"bytes"
"testing"

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

// TestVersionOverridesReservedFieldIsByteIdentical asserts that populating the
// reserved release.version_overrides.dir pointer produces byte-identical output
// to a config that leaves the block absent. The pointer is reserved shape but is
// not wired to generation.
func TestVersionOverridesReservedFieldIsByteIdentical(t *testing.T) {
t.Parallel()

// Case A: reserved version_overrides pointer populated.
cfgA := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
Deploys: []config.DeployConfig{
{
Name: "app",
Workflow: ".github/workflows/deploy-app.yaml",
},
},
Release: &config.ReleaseConfig{
VersionOverrides: &config.VersionOverridesConfig{
Dir: ".cascade/version-overrides",
},
},
}

// Case B: same base fields, reserved version_overrides block absent.
cfgB := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
Deploys: []config.DeployConfig{
{
Name: "app",
Workflow: ".github/workflows/deploy-app.yaml",
},
},
Release: &config.ReleaseConfig{},
}

gA := NewPromoteGenerator(cfgA, t.TempDir())
outA, err := gA.Generate()
require.NoError(t, err)

gB := NewPromoteGenerator(cfgB, t.TempDir())
outB, err := gB.Generate()
require.NoError(t, err)

// Guard against a vacuous comparison of two empty strings: the generator
// must have produced a substantial workflow for the equality to be meaningful.
require.NotEmpty(t, outA, "generated output must be non-empty")
require.Greater(t, len(outA), 1024, "generated workflow should be substantial")

require.True(t, bytes.Equal([]byte(outA), []byte(outB)),
"reserved version_overrides field must not affect generated output:\nwith field:\n%s\nwithout field:\n%s", outA, outB)
}
11 changes: 10 additions & 1 deletion internal/schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,16 @@
"description": "Release management settings.",
"properties": {
"disabled": { "type": "boolean", "description": "Disable cascade-managed releases." },
"tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." }
"tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." },
"version_overrides": { "$ref": "#/definitions/versionOverridesConfig" }
}
},
"versionOverridesConfig": {
"type": "object",
"additionalProperties": false,
"description": "Reserved pointer to the version-intent override-file location. Reserved shape only: parse and structural validation, no generator behavior.",
"properties": {
"dir": { "type": "string", "description": "Directory holding override files. Relative, no '..' segments. Empty means the implementation default (reserved)." }
}
},
"changelogConfig": {
Expand Down
11 changes: 10 additions & 1 deletion schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,16 @@
"description": "Release management settings.",
"properties": {
"disabled": { "type": "boolean", "description": "Disable cascade-managed releases." },
"tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." }
"tag": { "type": "string", "description": "callback.output reference for releases created by an external tool." },
"version_overrides": { "$ref": "#/definitions/versionOverridesConfig" }
}
},
"versionOverridesConfig": {
"type": "object",
"additionalProperties": false,
"description": "Reserved pointer to the version-intent override-file location. Reserved shape only: parse and structural validation, no generator behavior.",
"properties": {
"dir": { "type": "string", "description": "Directory holding override files. Relative, no '..' segments. Empty means the implementation default (reserved)." }
}
},
"changelogConfig": {
Expand Down
Loading