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
117 changes: 117 additions & 0 deletions e2e/scenarios/45-component-versioning.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: "Per-Component Version Scoping"
description: |
Exercises per-component version scoping (#287). The manifest declares two
components, each owning a path subtree with its own strict tag namespace. Each
generated per-component orchestrate workflow drives its setup step with its own
--component flag, so at runtime that component's version is derived from only its
path-scoped commits and its strict tag prefix (api-* never reads web-*, and vice
versa). The scenario seeds both subtrees, proves the multi-component generate then
verify roundtrip is drift-free, and asserts each orchestrate-<name>.yaml carries
its own --component invocation and path filter and not the sibling's. It then
advances only one component's subtree and reconfirms the drift-free roundtrip,
showing each component advances on its own path independently. The version math
itself (a commit under one component's path bumps only that component, in its own
tag namespace) is asserted in the git, config, version, and orchestrate unit
tests, which drive the computation directly; act cannot yet run a specific
per-component orchestrate workflow, so this scenario proves the generated wiring
and path isolation rather than executing the per-component version calculation.

config:
trunk_branch: main
environments: [dev, 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-
web:
path: services/web
tag_prefix: web-

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

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

func main() {}

- name: "Regenerate the per-component set and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0

- name: "Each per-component workflow scopes its own version derivation"
action: verify
verify:
regenerate: true
expect_exit: 0
# The observable, harness-robust proof of per-component version scoping is the
# emitted setup invocation: each orchestrate-<name>.yaml runs `cascade
# orchestrate setup ... --component <name>`, the flag that scopes that
# component's version to its own path and strict tag namespace at runtime. The
# run line is never rewritten by the harness (only the top-level name: is
# suffixed and setup-cli@ref localized), so these substrings are stable. The
# path filter and per-component concurrency group cross-check isolation, and
# not_contains proves neither file carries the sibling's scope.
expect:
workflow_files:
- path: ".github/workflows/orchestrate-api.yaml"
contains:
- "--component api"
- "- 'services/api/**'"
- "group: orchestrate-api-"
not_contains:
- "--component web"
- "services/web"
- path: ".github/workflows/orchestrate-web.yaml"
contains:
- "--component web"
- "- 'services/web/**'"
- "group: orchestrate-web-"
not_contains:
- "--component api"
- "services/api"
- path: ".github/workflows/orchestrate.yaml"
not_exists: true

- name: "Advance only the api subtree"
action: commit
commit:
message: "feat: add api handler"
files:
services/api/handler.go: |
package main

func handler() {}

- name: "Roundtrip stays drift-free after an isolated per-component change"
action: verify
verify:
regenerate: true
expect_exit: 0
# A source-only commit under one component's path does not alter the generated
# workflow set, and the api workflow still owns its own --component scope. This
# demonstrates each component advances on its own path independently.
expect:
workflow_files:
- path: ".github/workflows/orchestrate-api.yaml"
contains:
- "--component api"
not_contains:
- "--component web"
59 changes: 59 additions & 0 deletions internal/config/component_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package config

import "testing"

func twoComponentTrunk() *TrunkConfig {
return &TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
Components: map[string]ComponentConfig{
"api": {Path: "services/api", TagPrefix: "api-"},
"web": {Path: "services/web", TagPrefix: "web-"},
},
}
}

func TestGetComponentTagPrefix(t *testing.T) {
cfg := twoComponentTrunk()

got, err := cfg.GetComponentTagPrefix("api")
if err != nil {
t.Fatalf("GetComponentTagPrefix(api): %v", err)
}
if got != "api-" {
t.Errorf("GetComponentTagPrefix(api) = %q, want %q", got, "api-")
}

if _, err := cfg.GetComponentTagPrefix("missing"); err == nil {
t.Errorf("GetComponentTagPrefix(missing): expected error, got nil")
}
}

// TestResolvedComponent_TagGrammarSpec_ForcesStrictPrefix proves a component's
// derived grammar carries its own prefix AND forces StrictPrefix true, so the
// component reads only its own tag namespace even when no tag_grammar block set
// strict_prefix. This is the HLD Section 5 isolation invariant.
func TestResolvedComponent_TagGrammarSpec_ForcesStrictPrefix(t *testing.T) {
cfg := twoComponentTrunk()

resolved, err := cfg.ResolveComponent("api")
if err != nil {
t.Fatalf("ResolveComponent(api): %v", err)
}

spec := resolved.TagGrammarSpec()
if spec.Prefix != "api-" {
t.Errorf("spec.Prefix = %q, want %q", spec.Prefix, "api-")
}
if !spec.StrictPrefix {
t.Errorf("spec.StrictPrefix = false, want true (component must read strictly)")
}

// The strict api- grammar accepts its own tags and rejects a sibling's.
if !spec.IsVersionTag("api-1.2.3") {
t.Errorf("strict api- spec must accept api-1.2.3")
}
if spec.IsVersionTag("web-1.2.3") {
t.Errorf("strict api- spec must reject web-1.2.3 (namespace isolation)")
}
}
30 changes: 30 additions & 0 deletions internal/config/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"strings"

"github.com/stablekernel/cascade/internal/taggrammar"
)

// clone returns a fully independent deep copy of the config via a JSON round
Expand Down Expand Up @@ -42,6 +44,18 @@ func ComponentConcurrencyGroup(name string) string {
return fmt.Sprintf("orchestrate-%s-${{ github.ref }}", name)
}

// GetComponentTagPrefix returns the declared tag_prefix for the named component,
// the tag namespace that component's versions and tags live under. It errors when
// the component is not declared. Version and tag discovery use this so a
// component's scan is scoped to its own namespace and never a sibling's.
func (c *TrunkConfig) GetComponentTagPrefix(name string) (string, error) {
comp, ok := c.Components[name]
if !ok {
return "", fmt.Errorf("component %q is not declared", name)
}
return comp.TagPrefix, nil
}

// ResolvedComponent is the effective configuration for one component: its
// identity (Name), the subtree it owns (Path), and Config, a TrunkConfig holding
// the shared defaults with the component's overrides applied. Path is a
Expand Down Expand Up @@ -180,6 +194,22 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error)
return &ResolvedComponent{Name: name, Path: comp.Path, Config: eff}, nil
}

// TagGrammarSpec returns the tag grammar a component reads and emits its versions
// under: the component's resolved grammar (carrying its required tag_prefix and
// any inherited or overridden tag_grammar block) with StrictPrefix forced true.
// The strict flip is the HLD Section 5 isolation invariant: a component with a
// declared tag_prefix parses its tags literally so api-1.2.3 and web-1.2.3 never
// cross-match, and a nested-substring prefix (api- vs api-beta-) cannot collide
// either. It is forced on regardless of whether a tag_grammar block set
// strict_prefix, because the per-component namespace boundary is not optional. The
// implicit default (single-component) path does not call this; it keeps the
// permissive ResolveTagGrammar spec so single-component reads are unchanged.
func (r *ResolvedComponent) TagGrammarSpec() taggrammar.Spec {
spec := r.Config.ResolveTagGrammar()
spec.StrictPrefix = true
return spec
}

// globalOnlyComponentFields is the set of top-level-only (global) manifest keys
// that must never be overridden per component. It backs the targeted rejection
// message in validateComponents; any of these keys set under a component is a
Expand Down
26 changes: 26 additions & 0 deletions internal/generate/component_workflows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,32 @@ func TestOrchestrateTargets_Components_FanOut(t *testing.T) {
if strings.Contains(api, "orchestrate-web-${{ github.ref }}") {
t.Errorf("api workflow must not carry web's concurrency group (isolation)")
}

// Version scoping: each workflow passes its own --component so the setup step
// derives that component's version from its own path and tag namespace.
if !strings.Contains(api, "--component api") {
t.Errorf("api workflow setup missing --component api")
}
if strings.Contains(api, "--component web") {
t.Errorf("api workflow must not carry web's --component (isolation)")
}
if !strings.Contains(web, "--component web") {
t.Errorf("web workflow setup missing --component web")
}
}

// TestGenerator_SingleComponent_NoComponentFlag proves the single-component
// orchestrate workflow emits no --component flag, keeping its setup invocation
// byte-identical to the pre-component generator.
func TestGenerator_SingleComponent_NoComponentFlag(t *testing.T) {
cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}}
got, err := NewGenerator(cfg, "").Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if strings.Contains(got, "--component") {
t.Errorf("single-component workflow must not carry a --component flag")
}
}

// TestPlan_Components_MatchesGeneratedBytes proves that for a components:
Expand Down
7 changes: 7 additions & 0 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,13 @@ func (g *Generator) writeSetupJob(sb *strings.Builder) {
sb.WriteString(" cascade orchestrate setup \\\n")
}
fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath())
// A per-component orchestrate workflow scopes version derivation to its own
// component (path-scoped commits, strict tag namespace) by passing --component.
// The single-component workflow emits no --component line, so its output stays
// byte-identical.
if g.componentName != "" {
fmt.Fprintf(sb, " --component %s \\\n", g.componentName)
}
sb.WriteString(" --gha-output\n")

sb.WriteString("\n")
Expand Down
Loading