From 2c51ff9134e0b99f114647101bec3318db319deb Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 7 Jul 2026 23:05:10 -0400 Subject: [PATCH 1/2] feat(generate): fan out per-component orchestrate workflows When a manifest declares components, generate one orchestrate workflow per component: path-scoped triggers derived from the component path, a per-component concurrency group, a namespaced workflow name, and the component's resolved tag namespace. A single shared helper drives both the generate and verify (Plan) paths so they cannot disagree, proven byte-identical. A manifest with no components block generates byte-identical output to today. Deep-copy the resolved component config so no sibling derivation bleeds through shared pointer or slice fields. Promote, release, hotfix, and rollback stay repo-wide here; their per-component semantics land in the versioning, promotion, and lifecycle stages. Refs #283. Signed-off-by: Joshua Temple --- e2e/scenarios/44-components-reserved.yaml | 40 +++- internal/config/components.go | 44 +++- internal/config/components_deepcopy_test.go | 123 +++++++++++ internal/generate/command.go | 49 +++-- internal/generate/component_workflows_test.go | 201 ++++++++++++++++++ internal/generate/generator.go | 21 +- internal/generate/plan.go | 84 +++++++- 7 files changed, 514 insertions(+), 48 deletions(-) create mode 100644 internal/config/components_deepcopy_test.go create mode 100644 internal/generate/component_workflows_test.go diff --git a/e2e/scenarios/44-components-reserved.yaml b/e2e/scenarios/44-components-reserved.yaml index 0004ee89..4f6c2f99 100644 --- a/e2e/scenarios/44-components-reserved.yaml +++ b/e2e/scenarios/44-components-reserved.yaml @@ -1,11 +1,14 @@ -name: "Components Reserved Shape" +name: "Per-Component Workflow Generation" description: | - Exercises the reserved per-component descriptor map (config.components, #176). - Each component carries a path subtree and an optional tag_prefix. This block is - reserved and shape-only today: it parses and passes structural validation, but - carries no generator, state, or runtime behavior. The scenario declares two - components, generates the workflows, then regenerates and proves the output is - byte-identical with no drift. + Exercises per-component orchestrate workflow generation (#283). The manifest + declares two components, each owning a path subtree with a distinct tag prefix. + Generation fans the orchestrate lane out to one path-scoped, concurrency-isolated + workflow per component (orchestrate-api.yaml, orchestrate-web.yaml) and emits no + repo-wide orchestrate.yaml. The scenario seeds both subtrees, proves the + multi-component generate then verify roundtrip is drift-free and deterministic, + and proves the per-component files are distinct isolated artifacts. Deep per-file + structure (path filter, per-component concurrency group, namespaced workflow name) + is asserted in the generator unit tests. config: trunk_branch: main @@ -27,18 +30,33 @@ config: tag_prefix: web- steps: - - name: "Seed a minimal source tree" + - name: "Seed both component subtrees" action: commit commit: - message: "seed source" + message: "seed component sources" files: - src/main.go: | + services/api/main.go: | + package main + + func main() {} + services/web/main.go: | package main func main() {} - - name: "Regenerate and confirm no drift" + - name: "Regenerate the per-component set and confirm no drift" action: verify verify: regenerate: true expect_exit: 0 + + - name: "Prove the per-component workflows are distinct isolated files" + action: plan + plan: + mutate_path: .github/workflows/orchestrate-api.yaml + mutate_append: "\n# drift probe\n" + expect_exit: 0 + expect_contains: + - orchestrate-api.yaml + expect_not_contains: + - orchestrate-web.yaml diff --git a/internal/config/components.go b/internal/config/components.go index c0757963..5f4be0ee 100644 --- a/internal/config/components.go +++ b/internal/config/components.go @@ -1,6 +1,29 @@ package config -import "fmt" +import ( + "encoding/json" + "fmt" + "strings" +) + +// clone returns a fully independent deep copy of the config via a JSON round +// trip. Every TrunkConfig field carries a json tag and none is json:"-" (the +// sole json:"-" field, ComponentConfig.Extra, lives off this type), so the round +// trip reproduces the value exactly while breaking every pointer, slice, and map +// alias. ResolveComponent needs this: a shallow copy (eff := *c) would leave +// non-overridden pointer/slice/map fields aliasing the shared config, so one +// component's derivation could bleed into a sibling. +func (c *TrunkConfig) clone() (*TrunkConfig, error) { + data, err := json.Marshal(c) + if err != nil { + return nil, fmt.Errorf("cloning config: %w", err) + } + var dup TrunkConfig + if err := json.Unmarshal(data, &dup); err != nil { + return nil, fmt.Errorf("cloning config: %w", err) + } + return &dup, nil +} // HasComponents reports whether the manifest declares a components: block. When // it does not, the component dimension does not exist and the single-component @@ -46,7 +69,13 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error) return nil, fmt.Errorf("component %q is not declared", name) } - eff := *c // shallow copy: global fields carried through verbatim + // Deep-copy the shared config so non-overridden pointer/slice/map fields do + // not alias across sibling components. Global fields are carried through + // verbatim by the copy. + eff, err := c.clone() + if err != nil { + return nil, err + } eff.Components = nil // an effective per-component config has no nested components // Required per-component tag namespace. @@ -139,7 +168,16 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error) CancelInProgress: cancel, } - return &ResolvedComponent{Name: name, Path: comp.Path, Config: &eff}, nil + // Derive a path-scoped push-paths filter from the component subtree when + // neither the component nor the shared defaults set an explicit triggers + // list, so each component's orchestrate workflow fires only on changes under + // its own path. An explicit inherited or per-component triggers list (applied + // above) still wins. + if len(eff.Triggers) == 0 { + eff.Triggers = []string{strings.TrimRight(comp.Path, "/") + "/**"} + } + + return &ResolvedComponent{Name: name, Path: comp.Path, Config: eff}, nil } // globalOnlyComponentFields is the set of top-level-only (global) manifest keys diff --git a/internal/config/components_deepcopy_test.go b/internal/config/components_deepcopy_test.go new file mode 100644 index 00000000..a3300cd8 --- /dev/null +++ b/internal/config/components_deepcopy_test.go @@ -0,0 +1,123 @@ +package config + +import "testing" + +// baseComponentConfig returns a shared-default TrunkConfig declaring two +// components that inherit slice, map, and pointer-struct fields from the top +// level. It exercises the aliasing surface ResolveComponent must not leak +// across siblings. +func baseComponentConfig() *TrunkConfig { + return &TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + ActionPins: map[string]string{"actions/checkout": "v4"}, + Git: &GitConfig{UserName: "shared-bot", UserEmail: "bot@example.com"}, + Components: map[string]ComponentConfig{ + "api": {Path: "services/api", TagPrefix: "api-"}, + "web": {Path: "services/web", TagPrefix: "web-"}, + }, + } +} + +// TestResolveComponent_NoSiblingBleed proves the resolved effective config is a +// deep copy: mutating one component's slice, map, or pointer-struct field must +// not reach a sibling component's resolved config or the shared source config. A +// shallow copy (eff := *c) would alias the backing array/map/pointer and fail +// every assertion below. +func TestResolveComponent_NoSiblingBleed(t *testing.T) { + c := baseComponentConfig() + + api, err := c.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent(api): %v", err) + } + web, err := c.ResolveComponent("web") + if err != nil { + t.Fatalf("ResolveComponent(web): %v", err) + } + + // Slice: overwrite api's inherited environments in place. + api.Config.Environments[0] = "MUTATED" + if web.Config.Environments[0] != "dev" { + t.Errorf("slice bleed: web env[0] = %q, want dev", web.Config.Environments[0]) + } + if c.Environments[0] != "dev" { + t.Errorf("slice bleed into source: c env[0] = %q, want dev", c.Environments[0]) + } + + // Map: mutate api's inherited action pins. + api.Config.ActionPins["actions/checkout"] = "MUTATED" + if web.Config.ActionPins["actions/checkout"] != "v4" { + t.Errorf("map bleed: web pin = %q, want v4", web.Config.ActionPins["actions/checkout"]) + } + if c.ActionPins["actions/checkout"] != "v4" { + t.Errorf("map bleed into source: c pin = %q, want v4", c.ActionPins["actions/checkout"]) + } + + // Pointer struct: mutate api's inherited git identity. + api.Config.Git.UserName = "MUTATED" + if web.Config.Git.UserName != "shared-bot" { + t.Errorf("pointer bleed: web git user = %q, want shared-bot", web.Config.Git.UserName) + } + if c.Git.UserName != "shared-bot" { + t.Errorf("pointer bleed into source: c git user = %q, want shared-bot", c.Git.UserName) + } +} + +// TestResolveComponent_DerivesPathTrigger proves a component with no explicit +// triggers gets a push-paths filter scoped to its own subtree, so its +// orchestrate workflow only fires on changes under its path. +func TestResolveComponent_DerivesPathTrigger(t *testing.T) { + c := baseComponentConfig() + + api, err := c.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent(api): %v", err) + } + got := api.Config.GetAllTriggers() + want := []string{"services/api/**"} + if len(got) != 1 || got[0] != want[0] { + t.Errorf("api triggers = %v, want %v", got, want) + } + + web, err := c.ResolveComponent("web") + if err != nil { + t.Fatalf("ResolveComponent(web): %v", err) + } + if g := web.Config.GetAllTriggers(); len(g) != 1 || g[0] != "services/web/**" { + t.Errorf("web triggers = %v, want [services/web/**]", g) + } +} + +// TestResolveComponent_HonorsExplicitTriggers proves an inherited explicit +// triggers list wins over path derivation: the shared default filter is kept, not +// replaced by the component subtree. +func TestResolveComponent_HonorsExplicitTriggers(t *testing.T) { + c := baseComponentConfig() + c.Triggers = []string{"shared/**"} + + api, err := c.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent(api): %v", err) + } + if g := api.Config.GetAllTriggers(); len(g) != 1 || g[0] != "shared/**" { + t.Errorf("api triggers = %v, want [shared/**] (explicit inherited filter honored)", g) + } +} + +// TestResolveComponent_PerComponentTrigger proves a per-component triggers +// override wins over both path derivation and the shared default. +func TestResolveComponent_PerComponentTrigger(t *testing.T) { + c := baseComponentConfig() + comp := c.Components["api"] + comp.Triggers = []string{"custom/**"} + c.Components["api"] = comp + + api, err := c.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent(api): %v", err) + } + if g := api.Config.GetAllTriggers(); len(g) != 1 || g[0] != "custom/**" { + t.Errorf("api triggers = %v, want [custom/**] (per-component override honored)", g) + } +} diff --git a/internal/generate/command.go b/internal/generate/command.go index e1a8f420..520ee513 100644 --- a/internal/generate/command.go +++ b/internal/generate/command.go @@ -149,18 +149,19 @@ func runGenerateWorkflow(opts generateOptions) error { generateOrchestrate := !opts.promoteOnly generatePromote := !opts.orchestrateOnly - // Create orchestrate generator and validate - var orchestrateGen *Generator + // Create orchestrate generator target(s) and validate. A manifest declaring + // components: fans out to one path-scoped orchestrate-.yaml per + // component; otherwise a single orchestrate.yaml, byte-identical to today. + var orchTargets []orchestrateTarget if generateOrchestrate { - var orchestrateOpts []GeneratorOption - if opts.ownRepo { - orchestrateOpts = append(orchestrateOpts, WithOwnRepoRelease()) + orchTargets, err = orchestrateTargets(cfg, baseDir, opts.outputPath, manifestState, opts.ownRepo) + if err != nil { + return fmt.Errorf("planning orchestrate workflow: %w", err) } - orchestrateGen = NewGenerator(cfg, baseDir, orchestrateOpts...) - orchestrateGen.SetState(manifestState) - warnings := orchestrateGen.Validate() - for _, w := range warnings { - fmt.Fprintf(os.Stderr, "%s\n", w) + for _, t := range orchTargets { + for _, w := range t.Gen.Validate() { + fmt.Fprintf(os.Stderr, "%s\n", w) + } } } @@ -171,22 +172,24 @@ func runGenerateWorkflow(opts generateOptions) error { var generatedFiles []string - // Generate orchestrate workflow + // Generate orchestrate workflow(s) if generateOrchestrate { - content, err := orchestrateGen.Generate() - if err != nil { - return fmt.Errorf("generating orchestrate workflow: %w", err) - } + for _, t := range orchTargets { + content, err := t.Gen.Generate() + if err != nil { + return fmt.Errorf("generating orchestrate workflow %s: %w", t.Path, err) + } - if opts.dryRun { - fmt.Println("=== orchestrate.yaml ===") - fmt.Print(content) - } else { - if err := writeWorkflow(opts.outputPath, content, opts.force); err != nil { - return err + if opts.dryRun { + fmt.Printf("=== %s ===\n", filepath.Base(t.Path)) + fmt.Print(content) + } else { + if err := writeWorkflow(t.Path, content, opts.force); err != nil { + return err + } + generatedFiles = append(generatedFiles, t.Path) + fmt.Printf("Generated workflow: %s\n", t.Path) } - generatedFiles = append(generatedFiles, opts.outputPath) - fmt.Printf("Generated workflow: %s\n", opts.outputPath) } } diff --git a/internal/generate/component_workflows_test.go b/internal/generate/component_workflows_test.go new file mode 100644 index 00000000..0f6d49c8 --- /dev/null +++ b/internal/generate/component_workflows_test.go @@ -0,0 +1,201 @@ +package generate + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func twoComponentConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Components: map[string]config.ComponentConfig{ + "api": {Path: "services/api", TagPrefix: "api-"}, + "web": {Path: "services/web", TagPrefix: "web-"}, + }, + } +} + +// TestOrchestrateTargets_SingleComponent_ByteIdentical proves a manifest with no +// components: block takes the untouched single-generator path: exactly one target +// at the output path whose content is byte-identical to a directly built +// generator. This is the byte-identical guarantee for existing manifests. +func TestOrchestrateTargets_SingleComponent_ByteIdentical(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + + targets, err := orchestrateTargets(cfg, "", ".github/workflows/orchestrate.yaml", nil, false) + if err != nil { + t.Fatalf("orchestrateTargets: %v", err) + } + if len(targets) != 1 { + t.Fatalf("got %d targets, want 1", len(targets)) + } + if targets[0].Path != ".github/workflows/orchestrate.yaml" { + t.Errorf("path = %q, want .github/workflows/orchestrate.yaml", targets[0].Path) + } + + got, err := targets[0].Gen.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + want, err := NewGenerator(cfg, "").Generate() + if err != nil { + t.Fatalf("baseline Generate: %v", err) + } + if got != want { + t.Errorf("single-component output drifted from baseline generator") + } + if strings.Contains(got, "Orchestrate CI/CD (") { + t.Errorf("single-component output must not carry a component-namespaced name") + } +} + +// TestOrchestrateTargets_Components_FanOut proves a components: manifest fans out +// one path-scoped, concurrency-isolated, namespaced orchestrate file per +// component (sorted), and no repo-wide orchestrate.yaml. +func TestOrchestrateTargets_Components_FanOut(t *testing.T) { + cfg := twoComponentConfig() + + targets, err := orchestrateTargets(cfg, "", ".github/workflows/orchestrate.yaml", nil, false) + if err != nil { + t.Fatalf("orchestrateTargets: %v", err) + } + if len(targets) != 2 { + t.Fatalf("got %d targets, want 2", len(targets)) + } + + byPath := map[string]string{} + for _, tg := range targets { + content, gerr := tg.Gen.Generate() + if gerr != nil { + t.Fatalf("Generate(%s): %v", tg.Path, gerr) + } + byPath[tg.Path] = content + } + + api, ok := byPath[".github/workflows/orchestrate-api.yaml"] + if !ok { + t.Fatalf("missing orchestrate-api.yaml; got paths %v", keys(byPath)) + } + web, ok := byPath[".github/workflows/orchestrate-web.yaml"] + if !ok { + t.Fatalf("missing orchestrate-web.yaml; got paths %v", keys(byPath)) + } + if _, ok := byPath[".github/workflows/orchestrate.yaml"]; ok { + t.Errorf("repo-wide orchestrate.yaml must not be emitted when components are declared") + } + + // Naming: each file is namespaced to its component. + if !strings.Contains(api, "name: Orchestrate CI/CD (api)") { + t.Errorf("api workflow missing namespaced name") + } + if !strings.Contains(web, "name: Orchestrate CI/CD (web)") { + t.Errorf("web workflow missing namespaced name") + } + + // Path triggers: scoped to each component subtree. + if !strings.Contains(api, "services/api/**") { + t.Errorf("api workflow missing services/api/** path filter") + } + if strings.Contains(api, "services/web/**") { + t.Errorf("api workflow must not carry web's path filter (isolation)") + } + + // Concurrency: per-component group carrying the component identity. + if !strings.Contains(api, "group: orchestrate-api-${{ github.ref }}") { + t.Errorf("api workflow missing per-component concurrency group") + } + if strings.Contains(api, "orchestrate-web-${{ github.ref }}") { + t.Errorf("api workflow must not carry web's concurrency group (isolation)") + } +} + +// TestPlan_Components_MatchesGeneratedBytes proves that for a components: +// manifest the verify-side Plan enumeration is byte-identical to what the +// generate command writes: the fan-out (orchestrate-api.yaml + orchestrate-web.yaml, +// no repo-wide orchestrate.yaml) is produced identically by both seams, so verify +// never falsely reports drift or orphans on a multi-component repo. +func TestPlan_Components_MatchesGeneratedBytes(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github"), 0o755)) + + manifest := map[string]any{ + config.DefaultManifestKey: config.CICDFile{Config: twoComponentConfig()}, + } + body, err := yaml.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".github", "manifest.yaml"), body, 0o644)) + + chdir(t, dir) + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + dir = resolved + + opts := generateOptions{ + configPath: ".github/manifest.yaml", + manifestKey: config.DefaultManifestKey, + actionFolder: "manage-release", + outputPath: ".github/workflows/orchestrate.yaml", + promoteOutputPath: ".github/workflows/promote.yaml", + force: true, + } + require.NoError(t, runGenerateWorkflow(opts)) + + written := walkGeneratedFiles(t, dir) + // The repo-wide orchestrate.yaml must not exist; per-component files must. + require.NotContains(t, written, filepath.Join(".github", "workflows", "orchestrate.yaml"), + "components manifest must not emit a repo-wide orchestrate.yaml") + require.Contains(t, written, filepath.Join(".github", "workflows", "orchestrate-api.yaml")) + require.Contains(t, written, filepath.Join(".github", "workflows", "orchestrate-web.yaml")) + + planned, err := Plan(PlanOptions{ + ConfigPath: ".github/manifest.yaml", + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: ".github/workflows/orchestrate.yaml", + PromoteOutputPath: ".github/workflows/promote.yaml", + }) + require.NoError(t, err) + + paths := make([]string, len(planned)) + for i, p := range planned { + paths[i] = p.Path + } + sortedPaths := append([]string(nil), paths...) + sort.Strings(sortedPaths) + require.Equal(t, sortedPaths, paths, "Plan output must be sorted by Path") + + plannedByRel := make(map[string]string, len(planned)) + for _, p := range planned { + abs := p.Path + if !filepath.IsAbs(abs) { + abs = filepath.Join(dir, abs) + } + rel, rerr := filepath.Rel(dir, abs) + require.NoError(t, rerr) + plannedByRel[rel] = p.Content + } + + require.Equal(t, len(written), len(plannedByRel), + "Plan emitted a different number of files than generate wrote") + for rel, want := range written { + got, ok := plannedByRel[rel] + require.Truef(t, ok, "Plan did not emit %s that generate wrote", rel) + require.Equalf(t, want, got, "Plan content for %s differs from generated bytes", rel) + } +} + +func keys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index ef7ae93c..4b5e699f 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -144,6 +144,13 @@ type Generator struct { // mode, set only via WithOwnRepoRelease. See that option for the full // rationale. ownRepo bool + // componentName, when non-empty, names the component this orchestrate + // workflow is generated for. It namespaces the emitted workflow name so two + // components' orchestrate files are distinct. It is set only via + // WithComponentName by the per-component fan-out; the concurrency group and + // tag prefix already come from the resolved per-component config, so this + // affects the emitted name only. + componentName string } // GeneratorOption customizes a Generator. Options are the additive, variadic @@ -170,6 +177,14 @@ func WithOwnRepoRelease() GeneratorOption { return func(g *Generator) { g.ownRepo = true } } +// WithComponentName namespaces the generated orchestrate workflow to a declared +// component so a multi-component manifest emits one distinct orchestrate file per +// component. It sets the emitted workflow name; the per-component concurrency +// group and tag prefix already flow through the resolved per-component config. +func WithComponentName(name string) GeneratorOption { + return func(g *Generator) { g.componentName = name } +} + // NewGenerator creates a new workflow generator. Optional behavior is supplied // through the variadic GeneratorOption tail. func NewGenerator(cfg *config.TrunkConfig, baseDir string, opts ...GeneratorOption) *Generator { @@ -662,7 +677,11 @@ func (g *Generator) writeHeader(sb *strings.Builder) { } func (g *Generator) writeWorkflowTriggers(sb *strings.Builder) { - sb.WriteString("name: Orchestrate CI/CD\n\n") + if g.componentName != "" { + fmt.Fprintf(sb, "name: Orchestrate CI/CD (%s)\n\n", g.componentName) + } else { + sb.WriteString("name: Orchestrate CI/CD\n\n") + } sb.WriteString("on:\n") // release_trigger: dispatch drops the push: trigger so orchestrate runs only diff --git a/internal/generate/plan.go b/internal/generate/plan.go index 21fc9ce5..719e74bc 100644 --- a/internal/generate/plan.go +++ b/internal/generate/plan.go @@ -101,18 +101,21 @@ func Plan(opts PlanOptions) ([]PlannedFile, error) { var planned []PlannedFile - // 1. orchestrate -> outputPath (verify always treats the full set as enabled). - var orchestrateOpts []GeneratorOption - if opts.OwnRepo { - orchestrateOpts = append(orchestrateOpts, WithOwnRepoRelease()) - } - orchestrateGen := NewGenerator(cfg, baseDir, orchestrateOpts...) - orchestrateGen.SetState(manifestState) - content, err := orchestrateGen.Generate() + // 1. orchestrate -> outputPath, or one path-scoped orchestrate-.yaml per + // component when the manifest declares components: (verify always treats the + // full set as enabled). + orchTargets, err := orchestrateTargets(cfg, baseDir, outputPath, manifestState, opts.OwnRepo) if err != nil { - return nil, fmt.Errorf("generating orchestrate workflow: %w", err) + return nil, err + } + var content string + for _, t := range orchTargets { + content, err = t.Gen.Generate() + if err != nil { + return nil, fmt.Errorf("generating orchestrate workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: t.Path, Content: content}) } - planned = append(planned, PlannedFile{Path: outputPath, Content: content}) // 2. promote (multi-env) or release (single-env) -> promoteOutputPath. if cfg.IsSingleEnvironment() { @@ -216,6 +219,67 @@ func Plan(opts PlanOptions) ([]PlannedFile, error) { return planned, nil } +// orchestrateTarget pairs a rendered orchestrate workflow's target path with the +// generator that produces it. The generator is returned unrendered so callers can +// validate it (generate command) or render it (Plan) without duplicating the +// component fan-out decision. +type orchestrateTarget struct { + Path string + Gen *Generator +} + +// orchestrateTargets returns the orchestrate workflow target(s) the manifest +// emits. A manifest with no components: block yields exactly one target at +// outputPath, byte-identical to the pre-component generator. A manifest that +// declares components yields one path-scoped, concurrency-isolated, +// component-namespaced orchestrate-.yaml per component (sorted by name) and +// no repo-wide orchestrate file, because the repo is no longer a single +// orchestration unit. Both the generate command and Plan (verify's enumeration) +// call this, so they can never disagree on the per-component file set. +// +// This is the structural fan-out only. Per-component version, promotion, hotfix, +// and rollback semantics are deferred to their owning stages; the CLI invocations +// inside each generated workflow are unchanged from the single-component path. +func orchestrateTargets(cfg *config.TrunkConfig, baseDir, outputPath string, state map[string]*config.EnvState, ownRepo bool) ([]orchestrateTarget, error) { + var baseOpts []GeneratorOption + if ownRepo { + baseOpts = append(baseOpts, WithOwnRepoRelease()) + } + + if !cfg.HasComponents() { + gen := NewGenerator(cfg, baseDir, baseOpts...) + gen.SetState(state) + return []orchestrateTarget{{Path: outputPath, Gen: gen}}, nil + } + + names := make([]string, 0, len(cfg.Components)) + for name := range cfg.Components { + names = append(names, name) + } + sort.Strings(names) + + targets := make([]orchestrateTarget, 0, len(names)) + for _, name := range names { + resolved, err := cfg.ResolveComponent(name) + if err != nil { + return nil, fmt.Errorf("resolving component %q: %w", name, err) + } + genOpts := make([]GeneratorOption, 0, len(baseOpts)+1) + genOpts = append(genOpts, baseOpts...) + genOpts = append(genOpts, WithComponentName(name)) + gen := NewGenerator(resolved.Config, baseDir, genOpts...) + gen.SetState(state) + targets = append(targets, orchestrateTarget{Path: componentWorkflowPath(outputPath, name), Gen: gen}) + } + return targets, nil +} + +// componentWorkflowPath derives a per-component orchestrate workflow path from the +// base output path: the base directory plus orchestrate-.yaml. +func componentWorkflowPath(outputPath, name string) string { + return filepath.Join(filepath.Dir(outputPath), fmt.Sprintf("orchestrate-%s.yaml", name)) +} + // ResolveBaseDir reports the repo root the generate command resolves workflow // paths against for the given config path: the config's directory, promoted one // level up when the config lives in .github/. Callers that compare planned files From 5b1ed80a0c209d382aae44395a033c6449ec498c Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 7 Jul 2026 23:33:48 -0400 Subject: [PATCH 2/2] fix(e2e): stage and assert per-component orchestrate workflows in the harness The act+gitea harness gated repo staging on a hardcoded .github/workflows/orchestrate.yaml, but a components manifest emits per-component orchestrate-.yaml files and no repo-wide orchestrate.yaml, so staging failed. Accept any non-empty orchestrate*.yaml, preserving the original guard against generating no orchestrate workflow at all. Scenario 44 step 3 asserted file isolation via a plan diff, which the harness's post-generation name suffixing makes impossible (every workflow shows a name diff); reassert the observable per-component fan-out instead: each orchestrate-.yaml carries its own concurrency group and path filter (lines the harness does not rewrite) and no repo-wide orchestrate.yaml exists. Refs #283. Signed-off-by: Joshua Temple --- e2e/harness/harness.go | 40 ++++++++++++++--------- e2e/scenarios/44-components-reserved.yaml | 40 ++++++++++++++++++----- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index 7070d2dc..6d04a557 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -771,11 +771,12 @@ func (h *Harness) waitForBranchHead(ctx context.Context, wantSHA string) error { branchHeadPollAttempts, wantSHA, lastSHA) } -// assertOrchestrateGenerated confirms that .github/workflows/orchestrate.yaml -// exists and is non-empty in the act container's /tmp/repo immediately after -// generate-workflow. On failure it returns the generate output plus a listing -// of the workflows directory so the missing-file moment is captured with -// context rather than surfacing later as an opaque `cat: ... No such file`. +// assertOrchestrateGenerated confirms that at least one non-empty orchestrate +// workflow (orchestrate.yaml, or the per-component orchestrate-.yaml set) +// exists in the act container's /tmp/repo immediately after generate-workflow. On +// failure it returns the generate output plus a listing of the workflows directory +// so the missing-file moment is captured with context rather than surfacing later +// as an opaque `cat: ... No such file`. func (h *Harness) assertOrchestrateGenerated(ctx context.Context, genOutput string) error { present, exitCode, err := h.probeOrchestrateWorkflow(ctx) if present { @@ -789,23 +790,30 @@ func (h *Harness) assertOrchestrateGenerated(ctx context.Context, genOutput stri } return fmt.Errorf( "generate-workflow exited 0 but did not produce %s (exit=%d)\ngenerate output:\n%s\nworkflows dir:\n%s", - orchestrateWorkflowPath, exitCode, strings.TrimSpace(genOutput), + orchestrateWorkflowGlob, exitCode, strings.TrimSpace(genOutput), strings.TrimSpace(h.workflowsDirListing(ctx)), ) } -// orchestrateWorkflowPath is the generated workflow whose presence gates every -// orchestrate run. -const orchestrateWorkflowPath = ".github/workflows/orchestrate.yaml" - -// probeOrchestrateWorkflow reports whether orchestrate.yaml exists and is -// non-empty in /tmp/repo. The returned error is a docker-exec transport error -// (the probe could not be run), which callers treat as retryable and distinct -// from a clean "file absent" result (present=false, err=nil). +// orchestrateWorkflowGlob matches the generated orchestrate workflow(s) whose +// presence gates every orchestrate run: the single-component orchestrate.yaml, or +// the per-component orchestrate-.yaml set a manifest with components emits +// (in which case no repo-wide orchestrate.yaml exists). +const orchestrateWorkflowGlob = ".github/workflows/orchestrate*.yaml" + +// probeOrchestrateWorkflow reports whether at least one non-empty orchestrate +// workflow (orchestrate.yaml or a per-component orchestrate-.yaml) exists in +// /tmp/repo. The returned error is a docker-exec transport error (the probe could +// not be run), which callers treat as retryable and distinct from a clean "file +// absent" result (present=false, err=nil). func (h *Harness) probeOrchestrateWorkflow(ctx context.Context) (present bool, exitCode int, err error) { + // A non-matching glob stays a literal path, which `test -s` reports absent, so + // found stays 0 and the probe fails, preserving the original "generated no + // orchestrate workflow at all" guard for both the single- and multi-component + // forms. checkCmd := []string{ "bash", "-c", - "cd /tmp/repo && test -s " + orchestrateWorkflowPath, + "cd /tmp/repo && found=0; for f in " + orchestrateWorkflowGlob + "; do [ -s \"$f\" ] && found=1; done; [ \"$found\" = 1 ]", } exitCode, _, err = h.act.Container().Exec(ctx, checkCmd) if err != nil { @@ -1085,7 +1093,7 @@ func (h *Harness) SyncRepoToActContainer(ctx context.Context) error { lastErr = fmt.Errorf("workflow probe transport error (exit=%d): %w", probeExit, probeErr) continue } - lastErr = fmt.Errorf("%s absent after fetch/reset (exit=%d)", orchestrateWorkflowPath, probeExit) + lastErr = fmt.Errorf("%s absent after fetch/reset (exit=%d)", orchestrateWorkflowGlob, probeExit) } // Distinct from the generation-phase message: this is a sync/lost-commit diff --git a/e2e/scenarios/44-components-reserved.yaml b/e2e/scenarios/44-components-reserved.yaml index 4f6c2f99..c016a0ed 100644 --- a/e2e/scenarios/44-components-reserved.yaml +++ b/e2e/scenarios/44-components-reserved.yaml @@ -50,13 +50,35 @@ steps: regenerate: true expect_exit: 0 - - name: "Prove the per-component workflows are distinct isolated files" - action: plan - plan: - mutate_path: .github/workflows/orchestrate-api.yaml - mutate_append: "\n# drift probe\n" + - name: "Prove both per-component workflows exist as distinct isolated files" + action: verify + verify: + regenerate: true expect_exit: 0 - expect_contains: - - orchestrate-api.yaml - expect_not_contains: - - orchestrate-web.yaml + # The observable, harness-robust proof of the fan-out is the emitted file set + # itself: generation produced one path-scoped, concurrency-isolated workflow + # per component and no repo-wide orchestrate.yaml. Each file is asserted on the + # concurrency group and path filter, which the harness never rewrites (only the + # top-level name: is suffixed post-generation). not_contains cross-checks that + # neither file is a copy of the other, so the assertion still fails if the set + # collapses to one component or an empty set. File-set isolation on mutation is + # proven at the unit level (TestPlan_Components_MatchesGeneratedBytes and the + # deep-copy no-bleed tests), so it is not re-proven here via a plan diff. + expect: + workflow_files: + - path: ".github/workflows/orchestrate-api.yaml" + contains: + - "group: orchestrate-api-" + - "- 'services/api/**'" + not_contains: + - "group: orchestrate-web-" + - "services/web" + - path: ".github/workflows/orchestrate-web.yaml" + contains: + - "group: orchestrate-web-" + - "- 'services/web/**'" + not_contains: + - "group: orchestrate-api-" + - "services/api" + - path: ".github/workflows/orchestrate.yaml" + not_exists: true