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
133 changes: 133 additions & 0 deletions e2e/scenarios/55-component-env-subset.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: "Per-Component Environment Subset"
description: |
Proves a component promotes only through its own environment ladder when it
declares a shorter `environments` subset than the repo-global ladder (#297). The
manifest declares a global three-env ladder [dev, staging, prod] and two
components: "api" narrows its ladder to [dev, staging], while "web" inherits the
full global ladder. Each component owns a path subtree with its own strict tag
namespace and version line.

The proof is asymmetric. api's last env, staging, is the terminal position of its
ladder, so promoting api into staging is the publish crossing: api's staging state
lands the stripped release version api-0.1.0, not the api-0.1.0-rc.0 an ordinary
intermediate advance would carry. A promotion runtime that ignored api's subset
and walked the global ladder would treat staging as an intermediate hop (carrying
the rc version) and still hold prod as a further target. A cascade of api into the
global-only prod env is therefore rejected: prod is not on api's ladder. The
sibling web, on the full ladder, still promotes all the way to prod on its own
web-0.1.0 line, and api's recorded staging state stays byte-identical across web's
entire cycle.

config:
trunk_branch: main
environments: [dev, staging, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["services/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["services/**"]
components:
api:
path: services/api
tag_prefix: api-
environments: [dev, staging]
web:
path: services/web
tag_prefix: web-

steps:
- name: "Seed both component subtrees"
action: commit
commit:
message: "feat: seed component sources"
files:
services/api/main.go: |
package main

func main() {}
services/web/main.go: |
package main

func main() {}

# Confirm the multi-component generate then verify roundtrip is drift-free, so the
# per-component workflows executed below are the pristine generated output.
- name: "Regenerate the per-component set and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0

# Cut api's dev prerelease on its own version line.
- name: "Orchestrate api to cut its dev prerelease"
action: orchestrate
orchestrate:
component: api

# Promote api from dev to staging, api's last (terminal) env. Because staging is
# the top of api's ladder, this is the publish crossing: staging records the
# stripped release version api-0.1.0. Under a runtime that walked the global
# ladder, staging would be an intermediate hop still carrying api-0.1.0-rc.0. web
# has not been touched, so its subtree must be absent.
- name: "Promote api from dev to staging (its terminal env)"
action: promote
promote:
mode: cascade
target: staging
component: api
expect:
state:
api-staging:
component: api
env: staging
version: "api-0.1.0"
web-staging:
component: web
env: staging
wiped: true

# A cascade of api into the global-only prod env must be rejected: prod is not on
# api's ladder [dev, staging]. A runtime that ignored the subset would happily
# promote api into prod. The workflow fails at preflight.
- name: "Reject promoting api into the global-only prod env"
action: promote
promote:
mode: cascade
target: prod
component: api
expect_failure: true

# Advance web through its own cycle. web inherits the full ladder, so it reaches
# prod. Cutting web's dev prerelease must not disturb api's recorded staging state.
- name: "Orchestrate web to cut its dev prerelease"
action: orchestrate
orchestrate:
component: web
expect:
state:
api-staging:
component: api
env: staging
unchanged: true

# Promote web from dev to prod. web's full ladder reaches prod on its own web-0.1.0
# line; api's staging subtree must survive byte-identical.
- name: "Promote web from dev to prod (full ladder)"
action: promote
promote:
mode: cascade
target: prod
component: web
expect:
state:
web-prod:
component: web
env: prod
version: "web-0.1.0"
api-staging:
component: api
env: staging
unchanged: true
7 changes: 7 additions & 0 deletions internal/promote/command_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ func runPreflight(cmd *cobra.Command, args []string) error {
return err
}

// Narrow the working ladder to the component's resolved environment subset so
// the plan advances and gates only along that component's own environments,
// never the global-only tail. A no-op for a single-component (empty) preflight.
if err := applyComponentLadder(cicdFile, componentName); err != nil {
return err
}

// Parse and validate mode
// Mode can be "default" for sequential promotion, or a cascade target like "dev-to-prod"
var mode PromotionMode
Expand Down
163 changes: 163 additions & 0 deletions internal/promote/component_env_subset_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package promote

import (
"os"
"path/filepath"
"testing"

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

// componentEnvSubsetManifest declares a global three-env ladder [dev, staging,
// prod] and two components: "api" narrows its ladder to the strict subset [dev,
// staging] while "web" inherits the full global ladder. Each component seeds its
// own dev deployment under state.components.<name>.dev so a component-scoped
// promotion has a source to advance.
const componentEnvSubsetManifest = `ci:
config:
trunk_branch: main
environments: [dev, staging, prod]
components:
api:
path: services/api
tag_prefix: api-
environments: [dev, staging]
web:
path: services/web
tag_prefix: web-
state:
components:
api:
dev:
sha: apidevsha
version: api-1.0.0-rc.0
web:
dev:
sha: webdevsha
version: web-1.0.0-rc.0
`

func writeSubsetManifest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
require.NoError(t, os.WriteFile(path, []byte(componentEnvSubsetManifest), 0o644))
return path
}

// TestNewPromoter_ComponentSubset_CascadeToProdRejected proves the promotion
// runtime respects a component's narrowed environment ladder: "api" declares
// [dev, staging], so a cascade targeting the global-only "prod" env must be
// rejected because prod is not in api's resolved ladder. Before the runtime honored
// the subset it read the global [dev, staging, prod] ladder and would happily
// promote api into prod, an env api never targets.
func TestNewPromoter_ComponentSubset_CascadeToProdRejected(t *testing.T) {
path := writeSubsetManifest(t)

p, err := NewPromoter(PromoterOptions{
ConfigPath: path,
DryRun: true,
Actor: "test-actor",
Component: "api",
})
require.NoError(t, err)

result, err := p.Promote(ModeCascade, "dev-to-prod")
require.NoError(t, err)
require.False(t, result.Success, "cascade into prod must fail: prod is outside api's ladder [dev, staging]")
require.Contains(t, result.Error, "prod")
}

// TestNewPromoter_ComponentSubset_CascadeStaysInLadder proves a cascade within
// api's subset succeeds and never reaches beyond its last env. dev-to-staging is
// valid; staging is api's final env.
func TestNewPromoter_ComponentSubset_CascadeStaysInLadder(t *testing.T) {
path := writeSubsetManifest(t)

p, err := NewPromoter(PromoterOptions{
ConfigPath: path,
DryRun: true,
Actor: "test-actor",
Component: "api",
})
require.NoError(t, err)

result, err := p.Promote(ModeCascade, "dev-to-staging")
require.NoError(t, err)
require.True(t, result.Success, "dev-to-staging is inside api's ladder; error: %s", result.Error)
for _, promo := range result.Promotions {
require.NotEqual(t, "prod", promo.Environment, "api must never target prod")
}
require.Equal(t, "apidevsha", result.Promotions[len(result.Promotions)-1].SHA)
}

// TestNewPromoter_ComponentSubset_DefaultTreatsLastSubsetEnvAsFinal proves default
// (sequential) mode advances api only through its own ladder and treats staging,
// the last env of api's subset, as the final environment. No promotion targets the
// global-only prod env.
func TestNewPromoter_ComponentSubset_DefaultTreatsLastSubsetEnvAsFinal(t *testing.T) {
path := writeSubsetManifest(t)

p, err := NewPromoter(PromoterOptions{
ConfigPath: path,
DryRun: true,
Actor: "test-actor",
Component: "api",
})
require.NoError(t, err)

result, err := p.Promote(ModeDefault, "")
require.NoError(t, err)
require.True(t, result.Success, "default promotion must advance api within its ladder; error: %s", result.Error)
require.NotEmpty(t, result.Promotions)
for _, promo := range result.Promotions {
require.NotEqual(t, "prod", promo.Environment, "api must never target the global-only prod env")
}
}

// TestNewPromoter_ComponentSubset_SiblingKeepsFullLadder proves the narrowing is
// scoped to the addressed component: "web" inherits the full global ladder, so a
// cascade to prod succeeds and lands on prod.
func TestNewPromoter_ComponentSubset_SiblingKeepsFullLadder(t *testing.T) {
path := writeSubsetManifest(t)

p, err := NewPromoter(PromoterOptions{
ConfigPath: path,
DryRun: true,
Actor: "test-actor",
Component: "web",
})
require.NoError(t, err)

result, err := p.Promote(ModeCascade, "dev-to-prod")
require.NoError(t, err)
require.True(t, result.Success, "web keeps the full ladder; dev-to-prod must succeed; error: %s", result.Error)
require.Equal(t, "prod", result.Promotions[len(result.Promotions)-1].Environment)
require.Equal(t, "webdevsha", result.Promotions[len(result.Promotions)-1].SHA)
}

// TestApplyComponentLadder_EmptyComponentUnchanged proves the single-component
// (empty component) path leaves the global ladder byte-identical: applyComponentLadder
// is a no-op and the working config still carries the full global ladder.
func TestApplyComponentLadder_EmptyComponentUnchanged(t *testing.T) {
path := writeSubsetManifest(t)
cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey)
require.NoError(t, err)

before := append([]string(nil), cicdFile.Config.Environments...)
require.NoError(t, applyComponentLadder(cicdFile, ""))
require.Equal(t, before, cicdFile.Config.Environments, "empty component must not narrow the ladder")
require.Equal(t, []string{"dev", "staging", "prod"}, cicdFile.Config.Environments)
}

// TestApplyComponentLadder_NarrowsToComponentSubset proves the helper narrows the
// working config's ladder to the addressed component's resolved subset.
func TestApplyComponentLadder_NarrowsToComponentSubset(t *testing.T) {
path := writeSubsetManifest(t)
cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey)
require.NoError(t, err)

require.NoError(t, applyComponentLadder(cicdFile, "api"))
require.Equal(t, []string{"dev", "staging"}, cicdFile.Config.Environments)
}
56 changes: 56 additions & 0 deletions internal/promote/preflight_component_subset_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package promote

import (
"testing"

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

// TestPreflighter_ComponentSubset_TreatsLastSubsetEnvAsFinal proves the preflight
// planner honors a component's narrowed ladder end to end: for "api" (ladder [dev,
// staging]) a default-mode preflight advances dev->staging and marks staging as the
// final environment, never planning an advance into the global-only prod env.
func TestPreflighter_ComponentSubset_TreatsLastSubsetEnvAsFinal(t *testing.T) {
path := writeSubsetManifest(t)
cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey)
require.NoError(t, err)
require.NoError(t, overlayComponentState(cicdFile, path, "api"))
require.NoError(t, applyComponentLadder(cicdFile, "api"))

pf := NewPreflighter(PreflighterOptions{
Config: cicdFile,
Mode: ModeDefault,
})

result, err := pf.Run()
require.NoError(t, err)
for _, env := range result.EnvsToUpdate {
require.NotEqual(t, "prod", env, "api preflight must not plan an advance into prod")
}
// staging is api's last env, so crossing into it is the terminal publish
// boundary: the plan advances to the "release" marker and marks the crossing
// final, rather than trying to advance to the global-only prod env.
require.True(t, result.IsFinalEnv, "api's last-env crossing must be treated as final")
require.Equal(t, "release", result.TargetEnv, "the terminal crossing lands on the release marker, never prod")
}

// TestPreflighter_ComponentSubset_SiblingReachesProd proves the sibling "web",
// inheriting the full ladder, still plans a cascade all the way to prod.
func TestPreflighter_ComponentSubset_SiblingReachesProd(t *testing.T) {
path := writeSubsetManifest(t)
cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey)
require.NoError(t, err)
require.NoError(t, overlayComponentState(cicdFile, path, "web"))
require.NoError(t, applyComponentLadder(cicdFile, "web"))

pf := NewPreflighter(PreflighterOptions{
Config: cicdFile,
Mode: ModeCascade,
Target: "dev-to-prod",
})

result, err := pf.Run()
require.NoError(t, err)
require.Equal(t, "prod", result.TargetEnv, "web reaches prod on the full ladder")
}
Loading