From 1fdd3312d84b9c23f51076f86d17ada98a9041f5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 18 Jun 2026 12:45:43 -0400 Subject: [PATCH] feat: reserve components schema shape for per-component versioning Signed-off-by: Joshua Temple --- docs/src/content/docs/versioning.md | 67 ++++++++------------ internal/config/parse.go | 1 + internal/config/schema_v1_e2e_test.go | 27 ++++++++ internal/config/schema_v1_test.go | 87 ++++++++++++++++++++++++++ internal/config/types.go | 34 +++++++++++ internal/config/types_test.go | 88 +++++++++++++++++++++++++++ internal/config/validate_v1.go | 33 ++++++++++ 7 files changed, 295 insertions(+), 42 deletions(-) diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 4f12ec9e..b2acf6ac 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -3,9 +3,7 @@ title: Versioning and schema compatibility description: How the cascade manifest schema is versioned and how the CLI decides whether it can read a given manifest, including compatibility rules and the hotfix version segment. --- -The cascade manifest is the contract between your repository and the cascade -CLI. This document describes how the manifest schema is versioned and how the -CLI decides whether it can read a given manifest. +The cascade manifest is the contract between your repository and the cascade CLI. This document describes how the manifest schema is versioned and how the CLI decides whether it can read a given manifest. ## `schema_version` @@ -19,31 +17,23 @@ ci: # ... ``` -`schema_version` is a single monotonic integer, the "schema major". It is not a -semver string. It identifies which breaking-change generation of the schema the -manifest is written for. +`schema_version` is a single monotonic integer, the "schema major". It is not a semver string. It identifies which breaking-change generation of the schema the manifest is written for. ### Why an integer -The manifest evolves additively. New capabilities arrive as new optional fields, -new enum values, or new nested blocks, each with a sensible default. An older -CLI ignores fields it does not recognize, and a newer CLI fills in defaults for -fields an older manifest omits. Because of this, additive changes never change -`schema_version`. The integer only moves when a change is genuinely breaking: +The manifest evolves additively. New capabilities arrive as new optional fields, new enum values, or new nested blocks, each with a sensible default. An older CLI ignores fields it does not recognize, and a newer CLI fills in defaults for fields an older manifest omits. Because of this, additive changes never change `schema_version`. The integer only moves when a change is genuinely breaking: - a field is removed, - a field is re-typed, - the default behavior of an existing field changes. -A semver string would imply minor and patch schema axes that, given the -additive-only design, never need to exist. +A semver string would imply minor and patch schema axes that, given the additive-only design, never need to exist. ## Compatibility rules The CLI knows two bounds: -- `CurrentSchemaVersion` is the highest schema version this CLI understands. A - manifest that omits `schema_version` is assumed to target this version. +- `CurrentSchemaVersion` is the highest schema version this CLI understands. A manifest that omits `schema_version` is assumed to target this version. - `MinSchemaVersion` is the oldest schema version this CLI still reads. On load, the CLI applies the following rules: @@ -57,9 +47,7 @@ On load, the CLI applies the following rules: | above `CurrentSchemaVersion` | Rejected. The manifest needs a newer CLI; upgrade the `cli_version` pin. A newer schema may rely on changed semantics this CLI would mis-handle, so it does not guess. | | negative | Rejected as invalid. | -A rejected manifest is a fatal, generation-blocking condition: the CLI reports -the error and does not produce workflows. A warning is non-fatal and is surfaced -on stderr and in the `warnings` field of `parse-config` JSON output. +A rejected manifest is a fatal, generation-blocking condition: the CLI reports the error and does not produce workflows. A warning is non-fatal and is surfaced on stderr and in the `warnings` field of `parse-config` JSON output. ## Schema-version to CLI-version matrix @@ -71,44 +59,39 @@ This table is updated whenever `schema_version` is bumped. ## Deprecation window -A CLI supports the current schema version and the immediately preceding one -(N-1). When a new schema major lands, CLIs that ship with it continue to read -the previous major with a warning. A subsequent major may drop support for the -oldest major, at which point manifests at that version are rejected with a -pointer to the migration entry in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md). +A CLI supports the current schema version and the immediately preceding one (N-1). When a new schema major lands, CLIs that ship with it continue to read the previous major with a warning. A subsequent major may drop support for the oldest major, at which point manifests at that version are rejected with a pointer to the migration entry in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md). + +## Reserved shape: per-component versioning + +The manifest reserves the shape for independently versioned components that share one manifest. Three slots are frozen at `schema_version` 1: + +- A top-level `components` map, keyed by component name, where each entry carries an optional `path` (the subtree the component owns) and `tag_prefix` (its version-tag prefix). +- A matching `state..components` map that records the per-component version and SHA for an environment. +- A `latest_release.components` map that records the per-component published release. + +These slots parse and pass structural validation today, but carry no generator, state, or runtime behavior. A manifest may declare them without changing any generated workflow. Component names must be job-ID-safe (letters, digits, hyphens, underscores) and a configured `path` must be relative with no `..` segments, so a later release can attach behavior without re-typing the fields. + +That later release attaches behavior additively, so it does not bump `schema_version`: a manifest written against the reserved shape stays valid, and the schema-version-to-CLI matrix above is unchanged. ## 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. +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. ## Supported release line ### 0.x (current) -This is the active development line. Bug fixes, security patches, and new -capabilities all land here. No stability guarantee is made for the CLI command -surface or the manifest schema between 0.x releases. Additive changes arrive -without a `schema_version` bump. Breaking changes (field removals, type changes, -behaviour changes) increment `schema_version` and carry a `Migration` entry in -[CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md). +This is the active development line. Bug fixes, security patches, and new capabilities all land here. No stability guarantee is made for the CLI command surface or the manifest schema between 0.x releases. Additive changes arrive without a `schema_version` bump. Breaking changes (field removals, type changes, behaviour changes) increment `schema_version` and carry a `Migration` entry in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md). ### 1.0 When cascade reaches v1.0 the following guarantees apply: -- The CLI command surface (flags, subcommands, exit codes, JSON output shapes) - follows semver: breaking changes require a major version bump. -- The manifest schema follows the integer-major versioning described in this - document. An additive change never bumps `schema_version`; only a breaking - change does. -- The N-1 schema deprecation window (described above) is honoured across all - 1.x releases. +- The CLI command surface (flags, subcommands, exit codes, JSON output shapes) follows semver: breaking changes require a major version bump. +- The manifest schema follows the integer-major versioning described in this document. An additive change never bumps `schema_version`; only a breaking change does. +- The N-1 schema deprecation window (described above) is honoured across all 1.x releases. -Older tags outside the current release line do not receive backported fixes. -See [SECURITY.md](https://github.com/stablekernel/cascade/blob/main/SECURITY.md) for the security-patch policy. +Older tags outside the current release line do not receive backported fixes. See [SECURITY.md](https://github.com/stablekernel/cascade/blob/main/SECURITY.md) for the security-patch policy. ## Hotfix version segment diff --git a/internal/config/parse.go b/internal/config/parse.go index b2afcd44..52ddad2c 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -325,6 +325,7 @@ func Validate(cfg *TrunkConfig) []string { // Config-level structural validation for v1 reserved fields. errors = append(errors, validateConfigLevel(cfg)...) + errors = append(errors, validateComponents(cfg)...) // Validate release.tag reference if cfg.Release != nil && cfg.Release.Tag != "" { diff --git a/internal/config/schema_v1_e2e_test.go b/internal/config/schema_v1_e2e_test.go index a7b5b1c8..a69ad15f 100644 --- a/internal/config/schema_v1_e2e_test.go +++ b/internal/config/schema_v1_e2e_test.go @@ -50,6 +50,10 @@ const fullSurfaceManifest = `ci: environment_config: prod: gha_environment: production + components: + api: + path: services/api + tag_prefix: api-v validate: workflow: .github/workflows/validate.yaml supports_dry_run: true @@ -121,6 +125,21 @@ const fullSurfaceManifest = `ci: - sha: old123 version: v1.1.0 committed_at: "2026-01-01T00:00:00Z" + components: + api: + version: api-v1.0.0 + sha: abc123 + committed_at: "2026-01-01T00:00:00Z" + committed_by: github-actions[bot] + latest_release: + version: v1.2.0 + sha: abc123 + released_on: "2026-01-01T00:00:00Z" + components: + api: + version: api-v1.0.0 + sha: abc123 + released_on: "2026-01-01T00:00:00Z" ` func TestFullSurfaceManifestE2E(t *testing.T) { @@ -155,6 +174,9 @@ func TestFullSurfaceManifestE2E(t *testing.T) { if cfg.ExtraTriggers == nil || cfg.ExtraTriggers.MergeGroup == nil { t.Fatalf("extra_triggers not parsed: %#v", cfg.ExtraTriggers) } + if cfg.Components == nil || cfg.Components["api"].Path != "services/api" { + t.Fatalf("components reserved shape not parsed: %#v", cfg.Components) + } // Per-deployable version + previous ring survive the load path. st := cfg2State(t, path) @@ -164,6 +186,11 @@ func TestFullSurfaceManifestE2E(t *testing.T) { if len(st["prod"].Previous) != 1 { t.Fatalf("previous ring not parsed: %#v", st["prod"].Previous) } + + st2 := cfg2State(t, path) + if st2["prod"].Components == nil || st2["prod"].Components["api"].Version != "api-v1.0.0" { + t.Fatalf("state.prod.components not parsed: %#v", st2["prod"].Components) + } } // cfg2State reloads the manifest as a full CICDFile to inspect state. diff --git a/internal/config/schema_v1_test.go b/internal/config/schema_v1_test.go index 7c54c2f2..7449d083 100644 --- a/internal/config/schema_v1_test.go +++ b/internal/config/schema_v1_test.go @@ -848,3 +848,90 @@ external: } }) } + +// --- Reserved components shape (#176) --------------------------------------- + +func TestComponentsShapeParseAndValidate(t *testing.T) { + t.Run("valid components block passes", func(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [dev, prod] +components: + api: + path: services/api + tag_prefix: api-v + worker: + path: services/worker +`) + if cfg.Components == nil { + t.Fatal("Components map should be parsed") + } + if cfg.Components["api"].Path != "services/api" { + t.Fatalf("unexpected path: %q", cfg.Components["api"].Path) + } + if cfg.Components["api"].TagPrefix != "api-v" { + t.Fatalf("unexpected tag_prefix: %q", cfg.Components["api"].TagPrefix) + } + errs := Validate(cfg) + if len(errs) != 0 { + t.Fatalf("expected no errors, got: %v", errs) + } + }) + + t.Run("non-job-id-safe component name rejected", func(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + "bad name!": + path: services/bad +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "bad name!") { + t.Fatalf("expected error for invalid component name, got: %v", errs) + } + }) + + t.Run("path with dotdot rejected", func(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + mycomp: + path: ../outside +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "path") { + t.Fatalf("expected error for path with .., got: %v", errs) + } + }) + + t.Run("absolute path rejected", func(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + mycomp: + path: /absolute/path +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "path") { + t.Fatalf("expected error for absolute path, got: %v", errs) + } + }) + + t.Run("omitting components is nil and zero errors", func(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [dev, prod] +builds: + - name: app + workflow: .github/workflows/build.yaml + triggers: ["src/**"] +`) + if cfg.Components != nil { + t.Fatalf("expected nil Components, got: %v", cfg.Components) + } + errs := Validate(cfg) + if len(errs) != 0 { + t.Fatalf("expected no errors, got: %v", errs) + } + }) +} diff --git a/internal/config/types.go b/internal/config/types.go index a07817d3..1564654b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -20,6 +20,8 @@ type LatestReleaseState struct { SHA string `yaml:"sha,omitempty" json:"sha,omitempty"` // Commit SHA ReleasedOn string `yaml:"released_on,omitempty" json:"released_on,omitempty"` // ISO 8601 timestamp ReleasedBy string `yaml:"released_by,omitempty" json:"released_by,omitempty"` // GitHub actor who triggered release + // Components is the reserved per-component latest-release record (#176). + Components map[string]*ComponentReleaseState `yaml:"components,omitempty" json:"components,omitempty"` } // EnvState tracks the state of a single environment @@ -42,6 +44,9 @@ type EnvState struct { // prior env states, newest first, bounded to MaxPreviousSnapshots. Populated // on every state transition via PushPreviousSnapshot. Previous []EnvStateSnapshot `yaml:"previous,omitempty" json:"previous,omitempty"` + // Components is the reserved per-component state slot (#176): per-component + // version/sha records keyed by component name. Reserved-shape only. + Components map[string]*ComponentState `yaml:"components,omitempty" json:"components,omitempty"` } // IsDiverged reports whether the environment is on an integration branch rather @@ -142,6 +147,10 @@ type TrunkConfig struct { ActionPins map[string]string `yaml:"action_pins,omitempty" json:"action_pins,omitempty"` Telemetry *TelemetryConfig `yaml:"telemetry,omitempty" json:"telemetry,omitempty"` EnvironmentConfig map[string]EnvironmentConfig `yaml:"environment_config,omitempty" json:"environment_config,omitempty"` + // Components reserves the shape for independently versioned components sharing + // one manifest (#176). v1 contract: parse + structural validation only; no + // generator, state, or runtime behavior is attached. Absent by default. + Components map[string]ComponentConfig `yaml:"components,omitempty" json:"components,omitempty"` } // ConcurrencyConfig overrides the default concurrency: block emitted on the @@ -1003,6 +1012,31 @@ func (c *TrunkConfig) ResolveDependency(depRef string, fromType string) (string, return JobID(CallbackTypeExternal, depRef), nil } +// ComponentConfig is the reserved per-component descriptor. Only the addressing +// shape is frozen in v1; richer per-component config lands post-1.0 additively. +type ComponentConfig struct { + // Path is the subtree this component owns within the repo (reserved). + Path string `yaml:"path,omitempty" json:"path,omitempty"` + // TagPrefix is the per-component version tag prefix (reserved). Empty means + // inherit the manifest-level tag_prefix. + TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` +} + +// ComponentState is the reserved per-component recorded-state entry. +type ComponentState struct { + Version string `yaml:"version,omitempty" json:"version,omitempty"` + SHA string `yaml:"sha,omitempty" json:"sha,omitempty"` + CommittedAt string `yaml:"committed_at,omitempty" json:"committed_at,omitempty"` + CommittedBy string `yaml:"committed_by,omitempty" json:"committed_by,omitempty"` +} + +// ComponentReleaseState is the reserved per-component published-release entry. +type ComponentReleaseState struct { + Version string `yaml:"version,omitempty" json:"version,omitempty"` + SHA string `yaml:"sha,omitempty" json:"sha,omitempty"` + ReleasedOn string `yaml:"released_on,omitempty" json:"released_on,omitempty"` +} + // indexByte returns the index of the first instance of c in s, or -1 if c is not present func indexByte(s string, c byte) int { for i := 0; i < len(s); i++ { diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 0ac0cd05..b490bd9f 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" ) func TestGetPromotionModes(t *testing.T) { @@ -961,3 +962,90 @@ func TestResolveDependency_External(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "external-cdk", result) } + +func TestComponentsRoundTrip(t *testing.T) { + // Marshal a CICDFile with all three component map slots populated, + // unmarshal, marshal again, and assert the two encodings are equal. + original := &CICDFile{ + Config: &TrunkConfig{ + SchemaVersion: 1, + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Components: map[string]ComponentConfig{ + "api": {Path: "services/api", TagPrefix: "api-v"}, + }, + }, + State: map[string]*EnvState{ + "dev": { + SHA: "abc123", + Version: "v1.0.0", + Components: map[string]*ComponentState{ + "api": {Version: "api-v1.0.0", SHA: "abc123", CommittedAt: "2026-01-01T00:00:00Z"}, + }, + }, + }, + LatestRelease: &LatestReleaseState{ + Version: "v1.0.0", + SHA: "abc123", + ReleasedOn: "2026-01-01T00:00:00Z", + Components: map[string]*ComponentReleaseState{ + "api": {Version: "api-v1.0.0", SHA: "abc123", ReleasedOn: "2026-01-01T00:00:00Z"}, + }, + }, + } + + first, err := yaml.Marshal(original) + if err != nil { + t.Fatalf("first marshal: %v", err) + } + + var decoded CICDFile + if err := yaml.Unmarshal(first, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + second, err := yaml.Marshal(&decoded) + if err != nil { + t.Fatalf("second marshal: %v", err) + } + + if string(first) != string(second) { + t.Fatalf("round-trip mismatch:\nfirst:\n%s\nsecond:\n%s", first, second) + } + + // Verify decoded fields match original. + if decoded.Config.Components["api"].Path != "services/api" { + t.Fatalf("components.api.path not preserved: %q", decoded.Config.Components["api"].Path) + } + if decoded.State["dev"].Components["api"].Version != "api-v1.0.0" { + t.Fatalf("state.dev.components.api.version not preserved") + } + if decoded.LatestRelease.Components["api"].ReleasedOn != "2026-01-01T00:00:00Z" { + t.Fatalf("latest_release.components.api.released_on not preserved") + } +} + +func TestComponentsOmittedChangesNothing(t *testing.T) { + // A manifest without any components: key should produce identical output + // before and after the reserved-shape fields exist. nil map + omitempty + // guarantees the key never appears. + const src = `trunk_branch: main +environments: + - dev + - prod +` + var cfg TrunkConfig + if err := yaml.Unmarshal([]byte(src), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.Components != nil { + t.Fatalf("expected nil Components on manifest without components:, got %v", cfg.Components) + } + out, err := yaml.Marshal(&cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != src { + t.Fatalf("re-marshal changed output:\nwant:\n%s\ngot:\n%s", src, out) + } +} diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 430c5d05..994056b0 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -278,6 +278,39 @@ func validateConfigLevel(cfg *TrunkConfig) []string { return errs } +// validateComponents validates the reserved top-level components map (#176). +// Rules frozen at v1: component names must be job-ID-safe (so a future +// generator can key job IDs on the name without breakage), and any configured +// Path must be a clean relative path (no leading slash, no ".." segments). +func validateComponents(cfg *TrunkConfig) []string { + if len(cfg.Components) == 0 { + return nil + } + var errs []string + for _, name := range sortedComponentKeys(cfg.Components) { + errs = append(errs, validateJobIDSafeName("components."+name, name)...) + comp := cfg.Components[name] + if comp.Path != "" { + if strings.HasPrefix(comp.Path, "/") { + errs = append(errs, fmt.Sprintf("components.%s.path must be a relative path, not absolute", name)) + } else if strings.Contains(comp.Path, "..") { + errs = append(errs, fmt.Sprintf("components.%s.path must not contain '..' segments", name)) + } + } + } + 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)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // sortedKeys returns the keys of a string-valued map in deterministic order. func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m))