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
67 changes: 25 additions & 42 deletions docs/src/content/docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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.<env>.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

Expand Down
1 change: 1 addition & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
27 changes: 27 additions & 0 deletions internal/config/schema_v1_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions internal/config/schema_v1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
34 changes: 34 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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++ {
Expand Down
Loading
Loading