diff --git a/e2e/scenarios/50-component-promote-fanout.yaml b/e2e/scenarios/50-component-promote-fanout.yaml new file mode 100644 index 00000000..ee965ec1 --- /dev/null +++ b/e2e/scenarios/50-component-promote-fanout.yaml @@ -0,0 +1,94 @@ +name: "Per-Component Promote Generation" +description: | + Exercises per-component promote workflow generation (#291). The manifest declares + two components, each owning a path subtree with a distinct tag prefix. Generation + fans the promote lane out to one promote-.yaml per component + (promote-api.yaml, promote-web.yaml) and emits no repo-wide promote.yaml. Each + per-component promote workflow drives its promotion CLI steps with its own + --component flag, so at runtime that component's promotion records state under + only its own subtree, and carries a promote-namespaced concurrency group + (promote-) that is distinct from the component's orchestrate lane, so a + promote run and an orchestrate run for one component never serialize against each + other on a shared repo-global lane. The scenario seeds both subtrees, proves the + multi-component generate then verify roundtrip is drift-free, and asserts each + promote-.yaml carries its own --component invocation and promote group and + not the sibling's. Deep per-file structure and the manifest-global concurrency + composition are asserted in the generator unit tests; act cannot yet execute a + specific per-component promote workflow, so this scenario proves the generated + wiring and isolation rather than executing a per-component promotion. + +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 promote workflow scopes its own promotion state" + action: verify + verify: + regenerate: true + expect_exit: 0 + # The observable, harness-robust proof of the promote fan-out is the emitted + # file set plus each file's CLI wiring: generation produced one promote workflow + # per component and no repo-wide promote.yaml. Each promote-.yaml runs the + # promotion CLI with --component (the flag that scopes that component's + # promotion state to its own subtree at runtime) and carries a promote-namespaced + # concurrency group. The run line and the concurrency group are never rewritten + # by the harness (only the top-level name: is suffixed and setup-cli@ref + # localized), so these substrings are stable. not_contains proves neither file + # carries the sibling's scope, and that the promote group never reuses the + # orchestrate lane. + expect: + workflow_files: + - path: ".github/workflows/promote-api.yaml" + contains: + - "--component api" + - "group: promote-api" + not_contains: + - "--component web" + - "group: promote-web" + - "group: orchestrate-" + - path: ".github/workflows/promote-web.yaml" + contains: + - "--component web" + - "group: promote-web" + not_contains: + - "--component api" + - "group: promote-api" + - "group: orchestrate-" + - path: ".github/workflows/promote.yaml" + not_exists: true diff --git a/internal/config/components.go b/internal/config/components.go index 6fb07592..4f851210 100644 --- a/internal/config/components.go +++ b/internal/config/components.go @@ -44,6 +44,19 @@ func ComponentConcurrencyGroup(name string) string { return fmt.Sprintf("orchestrate-%s-${{ github.ref }}", name) } +// PromoteConcurrencyGroup derives the promote concurrency group for a named +// component. It lives in a dedicated "promote-" namespace, deliberately distinct +// from ComponentConcurrencyGroup's "orchestrate-" namespace, because GitHub +// concurrency groups are repo-global across workflows: a promote workflow that +// reused the orchestrate key would silently serialize or cancel against that +// component's orchestrate run. Like the single-component promote lane (which +// keys on the bare workflow name to serialize every promote run), this carries +// no ref or mode axis, so all of a component's promote runs serialize against +// each other; the component identity keeps two components from sharing a lane. +func PromoteConcurrencyGroup(name string) string { + return fmt.Sprintf("promote-%s", 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 diff --git a/internal/generate/command.go b/internal/generate/command.go index 520ee513..2cf46011 100644 --- a/internal/generate/command.go +++ b/internal/generate/command.go @@ -193,40 +193,53 @@ func runGenerateWorkflow(opts generateOptions) error { } } - // Generate promote/release workflow based on number of environments + // Generate promote/release workflow based on number of environments. A + // single-environment manifest gets one Release workflow; a multi-environment + // manifest gets the Promote workflow, fanned out to one promote-.yaml + // per component when components: is declared (byte-identical single + // promote.yaml otherwise). if generatePromote { - var content string - var err error - var workflowName string - if cfg.IsSingleEnvironment() { - // Single-environment projects get a simpler Release workflow - releaseGen := NewReleaseGenerator(cfg, baseDir) - content, err = releaseGen.Generate() - workflowName = "release" - } else { - // Multi-environment projects get the full Promote workflow - promoteGen := NewPromoteGenerator(cfg, baseDir) - promoteGen.SetState(manifestState) - content, err = promoteGen.Generate() - workflowName = "promote" - } - - if err != nil { - return fmt.Errorf("generating %s workflow: %w", workflowName, err) - } - - if opts.dryRun { - if generateOrchestrate { - fmt.Printf("\n=== %s.yaml ===\n", workflowName) + // Single-environment projects get a simpler Release workflow. + content, err := NewReleaseGenerator(cfg, baseDir).Generate() + if err != nil { + return fmt.Errorf("generating release workflow: %w", err) + } + if opts.dryRun { + if generateOrchestrate { + fmt.Printf("\n=== release.yaml ===\n") + } + fmt.Print(content) + } else { + if err := writeWorkflow(opts.promoteOutputPath, content, opts.force); err != nil { + return err + } + generatedFiles = append(generatedFiles, opts.promoteOutputPath) + fmt.Printf("Generated workflow: %s\n", opts.promoteOutputPath) } - fmt.Print(content) } else { - if err := writeWorkflow(opts.promoteOutputPath, content, opts.force); err != nil { - return err + promoteTargets, perr := promoteTargets(cfg, baseDir, opts.promoteOutputPath, manifestState) + if perr != nil { + return fmt.Errorf("planning promote workflow: %w", perr) + } + for _, t := range promoteTargets { + content, err := t.Gen.Generate() + if err != nil { + return fmt.Errorf("generating promote workflow %s: %w", t.Path, err) + } + if opts.dryRun { + if generateOrchestrate { + fmt.Printf("\n=== %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.promoteOutputPath) - fmt.Printf("Generated workflow: %s\n", opts.promoteOutputPath) } } diff --git a/internal/generate/plan.go b/internal/generate/plan.go index 719e74bc..c833e7b8 100644 --- a/internal/generate/plan.go +++ b/internal/generate/plan.go @@ -117,21 +117,29 @@ func Plan(opts PlanOptions) ([]PlannedFile, error) { planned = append(planned, PlannedFile{Path: t.Path, Content: content}) } - // 2. promote (multi-env) or release (single-env) -> promoteOutputPath. + // 2. promote (multi-env) or release (single-env). A single-env manifest keeps + // the single release workflow. A multi-env manifest with components: fans + // out to one promote-.yaml per component; otherwise a single + // promote.yaml, byte-identical to today. if cfg.IsSingleEnvironment() { content, err = NewReleaseGenerator(cfg, baseDir).Generate() if err != nil { return nil, fmt.Errorf("generating release workflow: %w", err) } + planned = append(planned, PlannedFile{Path: promoteOutputPath, Content: content}) } else { - promoteGen := NewPromoteGenerator(cfg, baseDir) - promoteGen.SetState(manifestState) - content, err = promoteGen.Generate() - if err != nil { - return nil, fmt.Errorf("generating promote workflow: %w", err) + promoteTargets, perr := promoteTargets(cfg, baseDir, promoteOutputPath, manifestState) + if perr != nil { + return nil, perr + } + for _, t := range promoteTargets { + content, err = t.Gen.Generate() + if err != nil { + return nil, fmt.Errorf("generating promote workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: t.Path, Content: content}) } } - planned = append(planned, PlannedFile{Path: promoteOutputPath, Content: content}) // 3. external-update -> .github/workflows/external-update.yaml when primary. if cfg.IsPrimary() { @@ -280,6 +288,67 @@ func componentWorkflowPath(outputPath, name string) string { return filepath.Join(filepath.Dir(outputPath), fmt.Sprintf("orchestrate-%s.yaml", name)) } +// promoteTarget pairs a rendered promote workflow's target path with the +// generator that produces it, mirroring orchestrateTarget so the generate command +// and Plan share one fan-out decision for the promote surface. +type promoteTarget struct { + Path string + Gen *PromoteGenerator +} + +// promoteTargets returns the promote workflow target(s) the manifest emits, +// mirroring orchestrateTargets. A manifest with no components: block yields +// exactly one target at outputPath, byte-identical to the pre-component promote +// generator. A manifest that declares components yields one promote-.yaml +// per component (sorted by name) and no repo-wide promote file, each generated +// from the resolved per-component config and scoped with --component so promotion +// records state under that component's subtree. The manifest-global +// concurrency.group is captured before resolution so the per-component promote +// group can compose it instead of collapsing onto it. Both the generate command +// and Plan call this, so they can never disagree on the promote file set. +// +// Callers gate on IsSingleEnvironment before invoking this: single-env manifests +// keep the release-workflow branch, so promoteTargets only ever runs on the +// multi-env promote path. +func promoteTargets(cfg *config.TrunkConfig, baseDir, outputPath string, state map[string]*config.EnvState) ([]promoteTarget, error) { + if !cfg.HasComponents() { + gen := NewPromoteGenerator(cfg, baseDir) + gen.SetState(state) + return []promoteTarget{{Path: outputPath, Gen: gen}}, nil + } + + var globalGroup string + if cfg.Concurrency != nil { + globalGroup = cfg.Concurrency.Group + } + + names := make([]string, 0, len(cfg.Components)) + for name := range cfg.Components { + names = append(names, name) + } + sort.Strings(names) + + targets := make([]promoteTarget, 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) + } + gen := NewPromoteGenerator(resolved.Config, baseDir, + WithPromoteComponentName(name), + WithPromoteGlobalConcurrencyGroup(globalGroup)) + gen.SetState(state) + targets = append(targets, promoteTarget{Path: promoteComponentWorkflowPath(outputPath, name), Gen: gen}) + } + return targets, nil +} + +// promoteComponentWorkflowPath derives a per-component promote workflow path from +// the base output path: the base directory plus promote-.yaml. +func promoteComponentWorkflowPath(outputPath, name string) string { + return filepath.Join(filepath.Dir(outputPath), fmt.Sprintf("promote-%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 diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 3a2a3e1f..aef57f58 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -21,16 +21,59 @@ type PromoteGenerator struct { // ${{ state.. }} references in deploy inputs at generation // time. Optional: nil when no state is threaded. state map[string]*config.EnvState + // componentName, when non-empty, names the component this promote workflow + // is generated for. It namespaces the emitted workflow name, scopes the + // promotion CLI steps with --component so promotion records state under this + // component's subtree, and switches writeConcurrency into a promote-namespaced + // per-component group. It is set only via WithPromoteComponentName by the + // per-component fan-out. + componentName string + // globalConcurrencyGroup carries the manifest-global concurrency.group as + // declared on the shared top-level config, captured before per-component + // resolution overwrites it. In component mode it is composed with the + // component identity so a global group scopes per component rather than + // collapsing every component's promote onto one repo-global lane. It is set + // only via WithPromoteGlobalConcurrencyGroup. + globalConcurrencyGroup string } -// NewPromoteGenerator creates a new promote workflow generator -func NewPromoteGenerator(cfg *config.TrunkConfig, baseDir string) *PromoteGenerator { - return &PromoteGenerator{ +// PromoteGeneratorOption customizes a PromoteGenerator. Options are the additive, +// variadic tail of NewPromoteGenerator so new behavior never changes the +// two-argument signature callers already depend on. +type PromoteGeneratorOption func(*PromoteGenerator) + +// WithPromoteComponentName namespaces the generated promote workflow to a +// declared component so a multi-component manifest emits one distinct +// promote-.yaml per component. It sets the emitted workflow name, threads +// --component through the promotion CLI steps, and selects the promote-namespaced +// per-component concurrency group. +func WithPromoteComponentName(name string) PromoteGeneratorOption { + return func(g *PromoteGenerator) { g.componentName = name } +} + +// WithPromoteGlobalConcurrencyGroup supplies the manifest-global +// concurrency.group as declared on the shared top-level config. The +// per-component fan-out captures it before resolution overwrites the group, so +// component-mode writeConcurrency can compose it with the component identity +// instead of honoring it bare (which would collapse every component's promote +// onto one lane). +func WithPromoteGlobalConcurrencyGroup(group string) PromoteGeneratorOption { + return func(g *PromoteGenerator) { g.globalConcurrencyGroup = group } +} + +// NewPromoteGenerator creates a new promote workflow generator. Optional behavior +// is supplied through the variadic PromoteGeneratorOption tail. +func NewPromoteGenerator(cfg *config.TrunkConfig, baseDir string, opts ...PromoteGeneratorOption) *PromoteGenerator { + g := &PromoteGenerator{ config: cfg, baseDir: baseDir, inputs: make(map[string][]string), requiredInputs: make(map[string][]string), } + for _, opt := range opts { + opt(g) + } + return g } // SetState threads the manifest state block into the promote generator so @@ -89,6 +132,16 @@ func (g *PromoteGenerator) getActionPath() string { return fmt.Sprintf("./.github/actions/%s", g.config.GetActionFolder()) } +// writeComponentFlag emits a "--component \" line at the given indent when +// this promote workflow is scoped to a component, so the promotion CLI records +// state under that component's subtree. The single-component workflow emits +// nothing, keeping its CLI invocations byte-identical. +func (g *PromoteGenerator) writeComponentFlag(sb *strings.Builder, indent string) { + if g.componentName != "" { + fmt.Fprintf(sb, "%s--component %s \\\n", indent, g.componentName) + } +} + // Generate creates the promote workflow content func (g *PromoteGenerator) Generate() (string, error) { // Discover inputs and required inputs for deploy workflows @@ -528,7 +581,11 @@ func (g *PromoteGenerator) writeHeader(sb *strings.Builder) { } func (g *PromoteGenerator) writeWorkflowTriggers(sb *strings.Builder) { - sb.WriteString("name: Promote\n\n") + if g.componentName != "" { + fmt.Fprintf(sb, "name: Promote (%s)\n\n", g.componentName) + } else { + sb.WriteString("name: Promote\n\n") + } sb.WriteString("on:\n") sb.WriteString(" workflow_dispatch:\n") sb.WriteString(" inputs:\n") @@ -684,6 +741,7 @@ func (g *PromoteGenerator) writePreflightJob(sb *strings.Builder) { fmt.Fprintf(sb, " --mode \"${PROMOTION_MODE:-default}\" \\\n") sb.WriteString(" --force=\"${PROMOTION_FORCE:-false}\" \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + g.writeComponentFlag(sb, " ") sb.WriteString(" --allow-breaking=\"${ALLOW_BREAKING:-false}\" \\\n") sb.WriteString(" --deploys=\"${DEPLOYS:-all}\" \\\n") sb.WriteString(" --rollback-on-failure=\"${ROLLBACK_ON_FAILURE:-true}\" \\\n") @@ -1305,6 +1363,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" run: |\n") fmt.Fprintf(sb, " cascade promote finalize \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + g.writeComponentFlag(sb, " ") sb.WriteString(" --promotion-result \"$PROMOTION_RESULT\" \\\n") sb.WriteString(" --repo \"${{ github.repository }}\" \\\n") sb.WriteString(" --run-id \"${{ github.run_id }}\" \\\n") @@ -1371,6 +1430,30 @@ func (g *PromoteGenerator) writeNativeDeploymentSteps(sb *strings.Builder) { // state and tags, so abandoning a mid-flight run leaves state partially written. func (g *PromoteGenerator) writeConcurrency(sb *strings.Builder) { sb.WriteString("concurrency:\n") + + // Component mode: emit a promote-namespaced per-component group and ignore + // the resolved config's group entirely. Two traps make the resolved group + // unusable here (see PromoteConcurrencyGroup): + // - the resolved config carries an orchestrate--... group, so + // honoring it would serialize this component's promote against its own + // orchestrate run (repo-global lane collision); + // - a manifest-global concurrency.group emitted bare would collapse every + // component's promote onto one literal lane. + // The composed key always carries the component identity, so two components + // never share a lane, and it never collides with the orchestrate namespace. + // cancel-in-progress stays false: promote mutates durable env state and tags, + // so queueing is safer than cancelling a mid-flight run. + if g.componentName != "" { + group := config.PromoteConcurrencyGroup(g.componentName) + if g.globalConcurrencyGroup != "" { + group = fmt.Sprintf("%s-%s", g.globalConcurrencyGroup, group) + } + fmt.Fprintf(sb, " group: %s\n", group) + sb.WriteString(" cancel-in-progress: false\n") + sb.WriteString("\n") + return + } + if g.config.Concurrency != nil && g.config.Concurrency.Group != "" { fmt.Fprintf(sb, " group: %s\n", g.config.Concurrency.Group) } else { diff --git a/internal/generate/promote_fanout_test.go b/internal/generate/promote_fanout_test.go new file mode 100644 index 00000000..0d825ceb --- /dev/null +++ b/internal/generate/promote_fanout_test.go @@ -0,0 +1,219 @@ +package generate + +import ( + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" +) + +// promoteMultiComponentConfig returns a two-component, multi-environment manifest +// suitable for exercising the promote fan-out. It has no builds or deploys, so +// generation reads no stub files from disk. +func promoteMultiComponentConfig() *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-"}, + }, + } +} + +// TestPromoteTargets_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 promote +// generator, with no component-namespaced name and no --component flag. +func TestPromoteTargets_SingleComponent_ByteIdentical(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + + targets, err := promoteTargets(cfg, "", ".github/workflows/promote.yaml", nil) + if err != nil { + t.Fatalf("promoteTargets: %v", err) + } + if len(targets) != 1 { + t.Fatalf("got %d targets, want 1", len(targets)) + } + if targets[0].Path != ".github/workflows/promote.yaml" { + t.Errorf("path = %q, want .github/workflows/promote.yaml", targets[0].Path) + } + + got, err := targets[0].Gen.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + want, err := NewPromoteGenerator(cfg, "").Generate() + if err != nil { + t.Fatalf("baseline Generate: %v", err) + } + if got != want { + t.Errorf("single-component promote output drifted from baseline generator") + } + if strings.Contains(got, "Promote (") { + t.Errorf("single-component promote output must not carry a component-namespaced name") + } + if strings.Contains(got, "--component") { + t.Errorf("single-component promote output must not carry a --component flag") + } +} + +// TestPromoteTargets_Components_FanOut proves a components: manifest fans out one +// promote-.yaml per component (sorted), no repo-wide promote.yaml, each +// namespaced, each scoped with its own --component and no sibling's. +func TestPromoteTargets_Components_FanOut(t *testing.T) { + cfg := promoteMultiComponentConfig() + + targets, err := promoteTargets(cfg, "", ".github/workflows/promote.yaml", nil) + if err != nil { + t.Fatalf("promoteTargets: %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/promote-api.yaml"] + if !ok { + t.Fatalf("missing promote-api.yaml; got paths %v", keys(byPath)) + } + web, ok := byPath[".github/workflows/promote-web.yaml"] + if !ok { + t.Fatalf("missing promote-web.yaml; got paths %v", keys(byPath)) + } + if _, ok := byPath[".github/workflows/promote.yaml"]; ok { + t.Errorf("repo-wide promote.yaml must not be emitted when components are declared") + } + + // Naming: each file is namespaced to its component. + if !strings.Contains(api, "name: Promote (api)") { + t.Errorf("api promote workflow missing namespaced name") + } + if !strings.Contains(web, "name: Promote (web)") { + t.Errorf("web promote workflow missing namespaced name") + } + + // Component scoping: each workflow passes its own --component on the promotion + // CLI steps so state is recorded under that component's subtree. + if !strings.Contains(api, "--component api") { + t.Errorf("api promote workflow missing --component api") + } + if strings.Contains(api, "--component web") { + t.Errorf("api promote workflow must not carry web's --component (isolation)") + } + if !strings.Contains(web, "--component web") { + t.Errorf("web promote workflow missing --component web") + } + // --component must land on both the preflight and finalize invocations. + if got := strings.Count(api, "--component api"); got < 2 { + t.Errorf("api promote workflow emitted --component api %d times, want >= 2 (preflight and finalize)", got) + } +} + +// TestPromoteTargets_ConcurrencyIsolation proves each component's promote group +// carries the component identity, lives in the promote namespace (never the +// orchestrate lane, Trap A), and no two components share a lane. +func TestPromoteTargets_ConcurrencyIsolation(t *testing.T) { + cfg := promoteMultiComponentConfig() + + targets, err := promoteTargets(cfg, "", ".github/workflows/promote.yaml", nil) + if err != nil { + t.Fatalf("promoteTargets: %v", err) + } + + 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 := byPath[".github/workflows/promote-api.yaml"] + web := byPath[".github/workflows/promote-web.yaml"] + + apiGroup := concurrencyGroupLine(t, api) + webGroup := concurrencyGroupLine(t, web) + + if apiGroup != " group: promote-api" { + t.Errorf("api promote group = %q, want %q", apiGroup, " group: promote-api") + } + if webGroup != " group: promote-web" { + t.Errorf("web promote group = %q, want %q", webGroup, " group: promote-web") + } + // Trap A: the promote group must never reuse the orchestrate lane, which + // GitHub would serialize against repo-globally. + if strings.Contains(apiGroup, "orchestrate-") { + t.Errorf("api promote group must not reuse the orchestrate lane: %q", apiGroup) + } + // Isolation: a component's promote group must not carry a sibling's identity. + if strings.Contains(apiGroup, "web") { + t.Errorf("api promote group must not carry web's identity: %q", apiGroup) + } + if apiGroup == webGroup { + t.Errorf("api and web promote groups must differ; both are %q", apiGroup) + } +} + +// TestPromoteTargets_GlobalConcurrencyGroupDoesNotCollapse proves Trap B: a +// manifest-global concurrency.group does not collapse every component's promote +// onto one literal lane. The component identity is still composed into each +// group, so the groups differ and neither is the bare global literal. +func TestPromoteTargets_GlobalConcurrencyGroupDoesNotCollapse(t *testing.T) { + cfg := promoteMultiComponentConfig() + cfg.Concurrency = &config.ConcurrencyConfig{Group: "shared-lane"} + + targets, err := promoteTargets(cfg, "", ".github/workflows/promote.yaml", nil) + if err != nil { + t.Fatalf("promoteTargets: %v", err) + } + + 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 + } + apiGroup := concurrencyGroupLine(t, byPath[".github/workflows/promote-api.yaml"]) + webGroup := concurrencyGroupLine(t, byPath[".github/workflows/promote-web.yaml"]) + + if apiGroup == " group: shared-lane" { + t.Errorf("api promote group collapsed onto the bare global literal: %q", apiGroup) + } + if apiGroup == webGroup { + t.Errorf("global concurrency.group collapsed both components onto one lane: %q", apiGroup) + } + // The component identity must remain present so isolation holds. + if !strings.Contains(apiGroup, "promote-api") { + t.Errorf("api promote group must retain the component identity: %q", apiGroup) + } + if !strings.Contains(webGroup, "promote-web") { + t.Errorf("web promote group must retain the component identity: %q", webGroup) + } +} + +// TestPromoteConcurrencyGroup_DistinctFromOrchestrate proves the promote and +// orchestrate concurrency namespaces never collide for the same component, so a +// promote run and an orchestrate run for one component never serialize against +// each other on a shared repo-global lane. +func TestPromoteConcurrencyGroup_DistinctFromOrchestrate(t *testing.T) { + const name = "api" + promote := config.PromoteConcurrencyGroup(name) + orchestrate := config.ComponentConcurrencyGroup(name) + if promote == orchestrate { + t.Fatalf("promote and orchestrate groups must differ for %q; both are %q", name, promote) + } + if !strings.HasPrefix(promote, "promote-") { + t.Errorf("promote group %q must live in the promote- namespace", promote) + } +}