diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 66417a5..b0b4b7b 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -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": { diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index f322cea..1b59760 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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). diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 79e4918..29a6d2d 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -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. diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index a0531d1..c11addb 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -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 diff --git a/e2e/scenarios/26-version-overrides-reserved.yaml b/e2e/scenarios/26-version-overrides-reserved.yaml new file mode 100644 index 0000000..0f23154 --- /dev/null +++ b/e2e/scenarios/26-version-overrides-reserved.yaml @@ -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 diff --git a/internal/config/parse.go b/internal/config/parse.go index 52ddad2..96102e2 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -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 != "" { diff --git a/internal/config/types.go b/internal/config/types.go index 01daa1a..05bfe80 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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 diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index d6cf890..19b063c 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -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)) diff --git a/internal/config/validate_versionoverrides_test.go b/internal/config/validate_versionoverrides_test.go new file mode 100644 index 0000000..3c79305 --- /dev/null +++ b/internal/config/validate_versionoverrides_test.go @@ -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) + } +} diff --git a/internal/generate/versionoverrides_reserved_test.go b/internal/generate/versionoverrides_reserved_test.go new file mode 100644 index 0000000..827de87 --- /dev/null +++ b/internal/generate/versionoverrides_reserved_test.go @@ -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) +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 66417a5..b0b4b7b 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -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": { diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 66417a5..b0b4b7b 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -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": {