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
20 changes: 19 additions & 1 deletion docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,25 @@
"items": { "type": "integer" },
"description": "Percent waves (for example [10, 50, 100])."
},
"analysis": { "type": "string", "description": "Workflow path that gates each wave." }
"analysis": { "type": "string", "description": "Workflow path that gates each wave." },
"percent": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "Single initial canary weight (1..100). RESERVED - not yet wired to generation."
},
"bake_time": {
"type": "string",
"description": "Soak duration after traffic shift before promotion (Go duration string, for example \"30m\"). RESERVED - not yet wired to generation."
},
"promote_callback": {
"type": "string",
"description": "Local callback workflow path invoked on promotion. RESERVED - not yet wired to generation."
},
"rollback_callback": {
"type": "string",
"description": "Local callback workflow path invoked on rollback. RESERVED - not yet wired to generation."
}
}
},
"blueGreenConfig": {
Expand Down
19 changes: 19 additions & 0 deletions docs/src/content/docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ These slots parse and pass structural validation today, but carry no generator,

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.

## Reserved shape: progressive rollout

The manifest reserves the shape for progressive rollout on a deploy callback. A deploy may declare a `rollout:` block with a `type` of `default`, `rolling`, `canary`, or `blue_green`, and an optional sub-block matching that type.

The `canary:` sub-block reserves four fields:

- `percent`, the initial canary weight, an integer from 1 to 100.
- `bake_time`, the soak duration before promotion, written as a Go duration string (for example `30m`).
- `promote_callback`, a local workflow path that performs the promotion.
- `rollback_callback`, a local workflow path that performs the rollback.

The `blue_green:` sub-block reserves one field:

- `switch`, the workflow path that performs the cutover.

These fields parse and pass structural validation today, but carry no generator 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`.

`matrix:` and `rollout:` are separate canonical concerns: `matrix:` describes the fan-out a callback runs across, and `rollout:` describes how a release advances through a callback. There is no shared `strategy:` block that combines them.

## 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
5 changes: 5 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ type DeployConfig struct {
// manifest untouched. See BuildConfig.Secrets for the accepted forms and the
// rationale for the generic value type.
Secrets any `yaml:"secrets,omitempty"`
// Rollout carries the rollout sub-block (type, canary, blue_green) through to
// the generated manifest untouched. A generic map keeps the harness decoupled
// from the generator's RolloutConfig shape, so a scenario can declare any
// reserved rollout field without the harness needing to know its structure.
Rollout map[string]any `yaml:"rollout,omitempty"`
}

// ConcurrencySpec defines the per-callback concurrency block written to trunk-config.yaml.
Expand Down
50 changes: 50 additions & 0 deletions e2e/scenarios/23-canary-bluegreen-reserved.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: "Canary and Blue/Green Reserved Shape"
description: |
Exercises the canary and blue_green rollout sub-blocks on deploy callbacks.
These blocks are reserved and shape-only today: they parse and pass structural
validation, but carry no generator behavior. The scenario declares both shapes
across two deploys, 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-canary
workflow: deploy-canary.yaml
triggers: ["src/**"]
rollout:
type: canary
canary:
percent: 10
bake_time: 5m
promote_callback: .github/workflows/promote.yaml
rollback_callback: .github/workflows/rollback.yaml
- name: app-bg
workflow: deploy-bg.yaml
triggers: ["src/**"]
rollout:
type: blue_green
blue_green:
switch: .github/workflows/switch.yaml

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
10 changes: 9 additions & 1 deletion internal/config/schema_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,18 @@ const (

// CanaryConfig is the reserved canary rollout sub-block.
type CanaryConfig struct {
// Steps are the percent waves (e.g. [10, 50, 100]).
// Steps are the percent waves (for example [10, 50, 100]).
Steps []int `yaml:"steps,omitempty" json:"steps,omitempty"`
// Analysis is a workflow path that gates each wave.
Analysis string `yaml:"analysis,omitempty" json:"analysis,omitempty"`
// Percent is the single initial canary weight (1..100). RESERVED - not yet wired to generation.
Percent int `yaml:"percent,omitempty" json:"percent,omitempty"`
// BakeTime is the soak duration after traffic shift before promotion (Go duration string, e.g. "30m"). RESERVED - not yet wired to generation.
BakeTime string `yaml:"bake_time,omitempty" json:"bake_time,omitempty"`
// PromoteCallback is the local callback workflow path invoked on promotion. RESERVED - not yet wired to generation.
PromoteCallback string `yaml:"promote_callback,omitempty" json:"promote_callback,omitempty"`
// RollbackCallback is the local callback workflow path invoked on rollback. RESERVED - not yet wired to generation.
RollbackCallback string `yaml:"rollback_callback,omitempty" json:"rollback_callback,omitempty"`
}

// BlueGreenConfig is the reserved blue/green rollout sub-block.
Expand Down
158 changes: 158 additions & 0 deletions internal/config/validate_canary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package config

import (
"strings"
"testing"
)

// TestValidateCanaryFields exercises the reserved canary sub-block validation
// rules added alongside the CanaryConfig field expansion.
func TestValidateCanaryFields(t *testing.T) {
t.Parallel()

t.Run("new canary fields validate at CurrentSchemaVersion", func(t *testing.T) {
t.Parallel()
cfg := parseInline(t, `
environments: [dev, prod]
deploys:
- name: app
workflow: .github/workflows/deploy.yaml
rollout:
type: canary
canary:
percent: 10
bake_time: 30m
promote_callback: .github/workflows/promote.yaml
rollback_callback: .github/workflows/rollback.yaml
`)
if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
})

t.Run("percent valid range", func(t *testing.T) {
t.Parallel()
for _, pct := range []int{1, 50, 100} {
pct := pct
t.Run("", func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{Percent: pct}
if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 {
t.Errorf("percent %d: expected no errors, got %v", pct, errs)
}
})
}
})

t.Run("percent zero is unset and valid", func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{Percent: 0}
if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 {
t.Fatalf("percent 0 (unset): expected no errors, got %v", errs)
}
})

t.Run("percent 101 rejected", func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{Percent: 101}
errs := validateCanaryConfig("deploys[0]", c)
if !hasErrContaining(errs, "rollout.canary.percent must be between 1 and 100") {
t.Fatalf("expected percent rejection, got %v", errs)
}
})

t.Run("percent -1 rejected", func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{Percent: -1}
errs := validateCanaryConfig("deploys[0]", c)
if !hasErrContaining(errs, "rollout.canary.percent must be between 1 and 100") {
t.Fatalf("expected percent rejection, got %v", errs)
}
})

t.Run("bake_time valid durations", func(t *testing.T) {
t.Parallel()
for _, d := range []string{"30s", "5m"} {
d := d
t.Run(d, func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{BakeTime: d}
if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 {
t.Errorf("bake_time %q: expected no errors, got %v", d, errs)
}
})
}
})

t.Run("bake_time invalid rejected", func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{BakeTime: "notaduration"}
errs := validateCanaryConfig("deploys[0]", c)
if !hasErrContaining(errs, "rollout.canary.bake_time must be a valid Go duration") {
t.Fatalf("expected bake_time rejection, got %v", errs)
}
})

t.Run("promote_callback valid paths", func(t *testing.T) {
t.Parallel()
for _, path := range []string{".github/workflows/x.yml", "x.yml"} {
path := path
t.Run(path, func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{PromoteCallback: path}
if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 {
t.Errorf("promote_callback %q: expected no errors, got %v", path, errs)
}
})
}
})

t.Run("promote_callback invalid paths rejected", func(t *testing.T) {
t.Parallel()
for _, path := range []string{"../evil.yml", "foo/bar.yml"} {
path := path
t.Run(path, func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{PromoteCallback: path}
errs := validateCanaryConfig("deploys[0]", c)
if !hasErrContaining(errs, "promote_callback") {
t.Errorf("promote_callback %q: expected rejection, got %v", path, errs)
}
for _, e := range errs {
if strings.Contains(e, "promote_callback") && !strings.Contains(e, "local callback workflow must be") {
t.Errorf("promote_callback %q: error has wrong message: %v", path, errs)
}
}
})
}
})

t.Run("rollback_callback valid paths", func(t *testing.T) {
t.Parallel()
for _, path := range []string{".github/workflows/x.yml", "x.yml"} {
path := path
t.Run(path, func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{RollbackCallback: path}
if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 {
t.Errorf("rollback_callback %q: expected no errors, got %v", path, errs)
}
})
}
})

t.Run("rollback_callback invalid paths rejected", func(t *testing.T) {
t.Parallel()
for _, path := range []string{"../evil.yml", "foo/bar.yml"} {
path := path
t.Run(path, func(t *testing.T) {
t.Parallel()
c := &CanaryConfig{RollbackCallback: path}
errs := validateCanaryConfig("deploys[0]", c)
if !hasErrContaining(errs, "rollback_callback") {
t.Errorf("rollback_callback %q: expected rejection, got %v", path, errs)
}
})
}
})
}
20 changes: 20 additions & 0 deletions internal/config/validate_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"regexp"
"sort"
"strings"
"time"
)

// Structural validation for the v1 reserved-shape fields. These rules are the
Expand Down Expand Up @@ -219,6 +220,25 @@ func validateRollout(prefix string, r *RolloutConfig, environments []string) []s
if (t == RolloutTypeCanary || t == RolloutTypeBlueGreen) && len(environments) == 0 {
errs = append(errs, fmt.Sprintf("%s.rollout.type %q requires environments to be configured", prefix, t))
}
if r.Canary != nil {
errs = append(errs, validateCanaryConfig(prefix, r.Canary)...)
}
return errs
}

// validateCanaryConfig checks the optional reserved fields on a CanaryConfig.
func validateCanaryConfig(prefix string, c *CanaryConfig) []string {
var errs []string
if c.Percent != 0 && (c.Percent < 1 || c.Percent > 100) {
errs = append(errs, fmt.Sprintf("%s.rollout.canary.percent must be between 1 and 100", prefix))
}
if c.BakeTime != "" {
if _, err := time.ParseDuration(c.BakeTime); err != nil {
errs = append(errs, fmt.Sprintf("%s.rollout.canary.bake_time must be a valid Go duration: %v", prefix, err))
}
}
errs = append(errs, validateLocalCallbackWorkflowPath(prefix+".rollout.canary.promote_callback", c.PromoteCallback)...)
errs = append(errs, validateLocalCallbackWorkflowPath(prefix+".rollout.canary.rollback_callback", c.RollbackCallback)...)
return errs
}

Expand Down
Loading
Loading