From 0414caa68695d37e6cba80211443314ba5dccc25 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 8 Jul 2026 06:46:45 -0400 Subject: [PATCH] feat(generate): fan out per-component hotfix and rollback workflows When a manifest declares components, generate one hotfix-.yaml and one rollback-.yaml per component, mirroring the per-component orchestrate and promote fan-out, each emitting --component so the hotfix and rollback commands run with component-scoped state, refs, and tags. A manifest with no components block generates the single hotfix and rollback workflows with no --component, byte-identical. The generate and verify paths share one fan-out helper for each so they cannot disagree. Refs #293, #294. Signed-off-by: Joshua Temple --- .../53-component-hotfix-rollback-fanout.yaml | 134 +++++++++ internal/config/components.go | 14 + internal/generate/command.go | 49 ++-- internal/generate/hotfix.go | 67 ++++- .../generate/hotfix_rollback_fanout_test.go | 277 ++++++++++++++++++ internal/generate/plan.go | 151 +++++++++- internal/generate/rollback.go | 87 +++++- 7 files changed, 743 insertions(+), 36 deletions(-) create mode 100644 e2e/scenarios/53-component-hotfix-rollback-fanout.yaml create mode 100644 internal/generate/hotfix_rollback_fanout_test.go diff --git a/e2e/scenarios/53-component-hotfix-rollback-fanout.yaml b/e2e/scenarios/53-component-hotfix-rollback-fanout.yaml new file mode 100644 index 00000000..92bc2e2d --- /dev/null +++ b/e2e/scenarios/53-component-hotfix-rollback-fanout.yaml @@ -0,0 +1,134 @@ +name: "Per-Component Hotfix and Rollback Generation" +description: | + Exercises per-component hotfix and rollback workflow generation (#293, #294). The + manifest declares two components, each owning a path subtree with a distinct tag + prefix. Generation fans the hotfix lane out to one cascade-hotfix-.yaml per + component (cascade-hotfix-api.yaml, cascade-hotfix-web.yaml) and the rollback lane + out to one cascade-rollback-.yaml per component, emitting no repo-wide + cascade-hotfix.yaml or cascade-rollback.yaml. Each per-component hotfix workflow + drives its plan and finalize CLI steps with its own --component flag, reads the + component's own N-1 state subtree (state.components..) for + auto-rollback, and carries a component-scoped concurrency group. Each rollback + workflow drives its preflight and finalize CLI steps with its own --component flag + and carries a rollback-namespaced per-component concurrency group (rollback-) + distinct from the component's orchestrate and promote lanes, so a rollback run and + an orchestrate or promote 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 + fanned-out file carries its own --component invocation, state subtree, and + concurrency 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 hotfix or rollback workflow, so + this scenario proves the generated wiring and isolation rather than executing a + per-component lifecycle. + +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 hotfix workflow scopes its own state and lane" + action: verify + verify: + regenerate: true + expect_exit: 0 + # The observable, harness-robust proof of the hotfix fan-out is the emitted file + # set plus each file's CLI wiring: generation produced one hotfix workflow per + # component and no repo-wide cascade-hotfix.yaml. Each cascade-hotfix-.yaml + # runs the hotfix CLI with --component (the flag that scopes plan and + # finalize state to that component's subtree at runtime), reads its own N-1 state + # under state.components. for auto-rollback, and carries a component-scoped + # concurrency group. The run lines, the yq state read, 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. + expect: + workflow_files: + - path: ".github/workflows/cascade-hotfix-api.yaml" + contains: + - "--component api" + - "state.components.api." + - "hotfix-finalize-api-" + not_contains: + - "--component web" + - "state.components.web." + - "hotfix-finalize-web-" + - path: ".github/workflows/cascade-hotfix-web.yaml" + contains: + - "--component web" + - "state.components.web." + - "hotfix-finalize-web-" + not_contains: + - "--component api" + - "state.components.api." + - "hotfix-finalize-api-" + - path: ".github/workflows/cascade-hotfix.yaml" + not_exists: true + + - name: "Each per-component rollback workflow scopes its own state and lane" + action: verify + verify: + regenerate: true + expect_exit: 0 + # Mirror of the hotfix assertion for the rollback fan-out. Each + # cascade-rollback-.yaml runs the rollback CLI (preflight and finalize) with + # --component and carries a rollback-namespaced concurrency group + # (rollback-). not_contains proves neither file carries the sibling's scope + # and that the rollback group never reuses the orchestrate or promote lane. + expect: + workflow_files: + - path: ".github/workflows/cascade-rollback-api.yaml" + contains: + - "--component api" + - "group: rollback-api" + not_contains: + - "--component web" + - "group: rollback-web" + - "group: orchestrate-" + - "group: promote-" + - path: ".github/workflows/cascade-rollback-web.yaml" + contains: + - "--component web" + - "group: rollback-web" + not_contains: + - "--component api" + - "group: rollback-api" + - "group: orchestrate-" + - "group: promote-" + - path: ".github/workflows/cascade-rollback.yaml" + not_exists: true diff --git a/internal/config/components.go b/internal/config/components.go index 4f851210..2dba7228 100644 --- a/internal/config/components.go +++ b/internal/config/components.go @@ -57,6 +57,20 @@ func PromoteConcurrencyGroup(name string) string { return fmt.Sprintf("promote-%s", name) } +// RollbackConcurrencyGroup derives the rollback concurrency group for a named +// component. It lives in a dedicated "rollback-" namespace, deliberately distinct +// from both ComponentConcurrencyGroup's "orchestrate-" namespace and +// PromoteConcurrencyGroup's "promote-" namespace, because GitHub concurrency +// groups are repo-global across workflows: a rollback workflow that reused either +// key would silently serialize or cancel against that component's orchestrate or +// promote run. Like the single-component rollback lane (which keys on the bare +// workflow name to serialize every rollback run), this carries no ref or mode +// axis, so all of a component's rollback runs serialize against each other; the +// component identity keeps two components from sharing a lane. +func RollbackConcurrencyGroup(name string) string { + return fmt.Sprintf("rollback-%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 2cf46011..3812b6f1 100644 --- a/internal/generate/command.go +++ b/internal/generate/command.go @@ -308,43 +308,52 @@ func runGenerateWorkflow(opts generateOptions) error { } } - // Generate the hotfix workflow when 2+ environments are configured (Q1). - hotfixGen := NewHotfixGenerator(cfg, baseDir) - if hotfixGen.Enabled() { - content, err := hotfixGen.Generate() + // Generate the hotfix workflow when two or more environments are configured. A + // manifest declaring components: fans out to one cascade-hotfix-.yaml per + // component; otherwise a single cascade-hotfix.yaml, byte-identical to today. + hfTargets, err := hotfixTargets(cfg, baseDir) + if err != nil { + return fmt.Errorf("planning hotfix workflow: %w", err) + } + for _, t := range hfTargets { + content, err := t.Gen.Generate() if err != nil { - return fmt.Errorf("generating hotfix workflow: %w", err) + return fmt.Errorf("generating hotfix workflow %s: %w", t.Path, err) } - outPath := ".github/workflows/cascade-hotfix.yaml" if opts.dryRun { - fmt.Println("\n=== cascade-hotfix.yaml ===") + fmt.Printf("\n=== %s ===\n", filepath.Base(t.Path)) fmt.Print(content) } else { - if err := writeWorkflow(outPath, content, opts.force); err != nil { + if err := writeWorkflow(t.Path, content, opts.force); err != nil { return err } - generatedFiles = append(generatedFiles, outPath) - fmt.Printf("Generated workflow: %s\n", outPath) + generatedFiles = append(generatedFiles, t.Path) + fmt.Printf("Generated workflow: %s\n", t.Path) } } - // Generate the rollback workflow when at least one environment is configured. - rollbackGen := NewRollbackGenerator(cfg, baseDir) - if rollbackGen.Enabled() { - content, err := rollbackGen.Generate() + // Generate the rollback workflow when two or more environments are configured. + // A manifest declaring components: fans out to one cascade-rollback-.yaml + // per component; otherwise a single cascade-rollback.yaml, byte-identical to + // today. + rbTargets, err := rollbackTargets(cfg, baseDir) + if err != nil { + return fmt.Errorf("planning rollback workflow: %w", err) + } + for _, t := range rbTargets { + content, err := t.Gen.Generate() if err != nil { - return fmt.Errorf("generating rollback workflow: %w", err) + return fmt.Errorf("generating rollback workflow %s: %w", t.Path, err) } - outPath := ".github/workflows/cascade-rollback.yaml" if opts.dryRun { - fmt.Println("\n=== cascade-rollback.yaml ===") + fmt.Printf("\n=== %s ===\n", filepath.Base(t.Path)) fmt.Print(content) } else { - if err := writeWorkflow(outPath, content, opts.force); err != nil { + if err := writeWorkflow(t.Path, content, opts.force); err != nil { return err } - generatedFiles = append(generatedFiles, outPath) - fmt.Printf("Generated workflow: %s\n", outPath) + generatedFiles = append(generatedFiles, t.Path) + fmt.Printf("Generated workflow: %s\n", t.Path) } } diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 3c0ffbf1..7556868e 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -40,15 +40,50 @@ const hotfixConflictLabel = "cascade-hotfix-conflict" type HotfixGenerator struct { config *config.TrunkConfig baseDir string + + // componentName, when non-empty, names the component this hotfix workflow is + // scoped to. It suffixes the workflow name, composes the component identity + // into the concurrency group, threads --component through the hotfix CLI steps + // so plan and finalize record state under this component's subtree, and points + // the context job's raw N-1 state read at state.components.. It is set + // only via WithHotfixComponentName by the per-component fan-out. + componentName string +} + +// HotfixGeneratorOption configures a HotfixGenerator. Options are additive so new +// per-component capability never breaks the positional constructor signature. +type HotfixGeneratorOption func(*HotfixGenerator) + +// WithHotfixComponentName scopes the generated hotfix workflow to a declared +// component so a multi-component manifest emits one distinct cascade-hotfix-.yaml +// per component. It sets the emitted workflow name, threads --component through the +// hotfix CLI steps, composes the component into the concurrency group, and points +// the context job's rollback-SHA read at the component's state subtree. +func WithHotfixComponentName(name string) HotfixGeneratorOption { + return func(g *HotfixGenerator) { g.componentName = name } } // NewHotfixGenerator creates a hotfix-workflow generator bound to the given // trunk config and repository base directory. -func NewHotfixGenerator(cfg *config.TrunkConfig, baseDir string) *HotfixGenerator { - return &HotfixGenerator{ +func NewHotfixGenerator(cfg *config.TrunkConfig, baseDir string, opts ...HotfixGeneratorOption) *HotfixGenerator { + g := &HotfixGenerator{ config: cfg, baseDir: baseDir, } + for _, opt := range opts { + opt(g) + } + return g +} + +// writeComponentFlag emits a "--component \" continuation line at the given +// indent when this hotfix workflow is scoped to a component, so the hotfix CLI +// records and reads state under that component's subtree. The single-component +// workflow emits nothing, keeping its CLI invocations byte-identical. +func (g *HotfixGenerator) writeComponentFlag(sb *strings.Builder, indent string) { + if g.componentName != "" { + fmt.Fprintf(sb, "%s--component %s \\\n", indent, g.componentName) + } } // getStateTokenRef returns the token expression used to merge the clean-path @@ -131,7 +166,11 @@ func (g *HotfixGenerator) writeHeader(sb *strings.Builder) { } func (g *HotfixGenerator) writeTriggers(sb *strings.Builder) { - sb.WriteString("name: Cascade Hotfix\n\n") + if g.componentName != "" { + fmt.Fprintf(sb, "name: Cascade Hotfix (%s)\n\n", g.componentName) + } else { + sb.WriteString("name: Cascade Hotfix\n\n") + } sb.WriteString("on:\n") sb.WriteString(" workflow_dispatch:\n") sb.WriteString(" inputs:\n") @@ -194,7 +233,15 @@ func (g *HotfixGenerator) writePermissions(sb *strings.Builder) { // (orchestrate, promote, rollback) that a manifest-global group cannot serialize. func (g *HotfixGenerator) writeConcurrency(sb *strings.Builder) { sb.WriteString("concurrency:\n") - sb.WriteString(" group: ${{ github.event_name == 'pull_request' && format('hotfix-finalize-{0}', github.repository) || format('hotfix-{0}', github.event.inputs.target_env) }}\n") + if g.componentName != "" { + // Bake the component identity into both the finalize (per-repo) and apply + // (per-target-env) lanes so two components' hotfixes into the same env do + // not collide on one repo-global group, mirroring the promote fan-out's + // per-component isolation. + fmt.Fprintf(sb, " group: ${{ github.event_name == 'pull_request' && format('hotfix-finalize-%s-{0}', github.repository) || format('hotfix-%s-{0}', github.event.inputs.target_env) }}\n", g.componentName, g.componentName) + } else { + sb.WriteString(" group: ${{ github.event_name == 'pull_request' && format('hotfix-finalize-{0}', github.repository) || format('hotfix-{0}', github.event.inputs.target_env) }}\n") + } sb.WriteString(" cancel-in-progress: false\n") sb.WriteString("\n") } @@ -262,6 +309,7 @@ func (g *HotfixGenerator) writePlanJob(sb *strings.Builder) { sb.WriteString(" run: |\n") sb.WriteString(" cascade hotfix plan \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + g.writeComponentFlag(sb, " ") sb.WriteString(" --commits \"$HOTFIX_COMMIT\" \\\n") sb.WriteString(" --target-env \"$HOTFIX_TARGET_ENV\" \\\n") // --repo wires the single-flight PR lookup to a real REST-backed checker. @@ -560,7 +608,15 @@ func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) { // ".$MANIFEST_KEY.state..sha" read. fmt.Fprintf(sb, " MANIFEST_FILE=\"%s\"\n", g.getManifestFilePath()) fmt.Fprintf(sb, " MANIFEST_KEY=\"%s\"\n", g.config.GetManifestKey()) - sb.WriteString(" ROLLBACK_SHA=$(yq eval \".$MANIFEST_KEY.state.${TARGET_ENV}.sha // \\\"\\\"\" \"$MANIFEST_FILE\")\n") + // Component-scoped state nests under state.components..; the + // single-component form is the flat state.. The read must match the scope + // the finalize CLI wrote at so auto-rollback resolves this component's own N-1 + // SHA (and cannot read a sibling's). + if g.componentName != "" { + fmt.Fprintf(sb, " ROLLBACK_SHA=$(yq eval \".$MANIFEST_KEY.state.components.%s.${TARGET_ENV}.sha // \\\"\\\"\" \"$MANIFEST_FILE\")\n", g.componentName) + } else { + sb.WriteString(" ROLLBACK_SHA=$(yq eval \".$MANIFEST_KEY.state.${TARGET_ENV}.sha // \\\"\\\"\" \"$MANIFEST_FILE\")\n") + } sb.WriteString(" if [ \"$ROLLBACK_SHA\" = \"null\" ]; then ROLLBACK_SHA=\"\"; fi\n") sb.WriteString(" {\n") sb.WriteString(" echo \"target_env=${TARGET_ENV}\"\n") @@ -755,6 +811,7 @@ func (g *HotfixGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" run: |\n") sb.WriteString(" cascade hotfix finalize \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + g.writeComponentFlag(sb, " ") sb.WriteString(" --target-env \"$TARGET_ENV\" \\\n") sb.WriteString(" --merge-sha \"$MERGE_SHA\" \\\n") sb.WriteString(" --fix-sha \"$FIX_SHA\" \\\n") diff --git a/internal/generate/hotfix_rollback_fanout_test.go b/internal/generate/hotfix_rollback_fanout_test.go new file mode 100644 index 00000000..0a5c7c50 --- /dev/null +++ b/internal/generate/hotfix_rollback_fanout_test.go @@ -0,0 +1,277 @@ +package generate + +import ( + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" +) + +// lifecycleMultiComponentConfig returns a two-component, multi-environment +// manifest suitable for exercising the hotfix and rollback fan-out. It has no +// builds or deploys, so generation reads no stub files from disk. +func lifecycleMultiComponentConfig() *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-"}, + }, + } +} + +// TestHotfixTargets_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 hotfix +// generator, with no component-namespaced name and no --component flag. +func TestHotfixTargets_SingleComponent_ByteIdentical(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + + targets, err := hotfixTargets(cfg, "") + if err != nil { + t.Fatalf("hotfixTargets: %v", err) + } + if len(targets) != 1 { + t.Fatalf("got %d targets, want 1", len(targets)) + } + if targets[0].Path != ".github/workflows/cascade-hotfix.yaml" { + t.Errorf("path = %q, want .github/workflows/cascade-hotfix.yaml", targets[0].Path) + } + + got, err := targets[0].Gen.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + want, err := NewHotfixGenerator(cfg, "").Generate() + if err != nil { + t.Fatalf("baseline Generate: %v", err) + } + if got != want { + t.Errorf("single-component hotfix output drifted from baseline generator") + } + if strings.Contains(got, "Cascade Hotfix (") { + t.Errorf("single-component hotfix output must not carry a component-namespaced name") + } + if strings.Contains(got, "--component") { + t.Errorf("single-component hotfix output must not carry a --component flag") + } + if strings.Contains(got, "state.components.") { + t.Errorf("single-component hotfix output must read the flat state. path, not state.components.") + } +} + +// TestHotfixTargets_Components_FanOut proves a components: manifest fans out one +// cascade-hotfix-.yaml per component (sorted), no repo-wide +// cascade-hotfix.yaml, each namespaced, each scoped with its own --component and +// no sibling's, and each reading its own component state subtree. +func TestHotfixTargets_Components_FanOut(t *testing.T) { + cfg := lifecycleMultiComponentConfig() + + targets, err := hotfixTargets(cfg, "") + if err != nil { + t.Fatalf("hotfixTargets: %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/cascade-hotfix-api.yaml"] + if !ok { + t.Fatalf("missing cascade-hotfix-api.yaml; got paths %v", keys(byPath)) + } + web, ok := byPath[".github/workflows/cascade-hotfix-web.yaml"] + if !ok { + t.Fatalf("missing cascade-hotfix-web.yaml; got paths %v", keys(byPath)) + } + if _, ok := byPath[".github/workflows/cascade-hotfix.yaml"]; ok { + t.Errorf("repo-wide cascade-hotfix.yaml must not be emitted when components are declared") + } + + // Naming: each file is namespaced to its component. + if !strings.Contains(api, "name: Cascade Hotfix (api)") { + t.Errorf("api hotfix workflow missing namespaced name") + } + if !strings.Contains(web, "name: Cascade Hotfix (web)") { + t.Errorf("web hotfix workflow missing namespaced name") + } + + // Component scoping: each workflow passes its own --component on the hotfix + // CLI steps (plan and finalize) so state is recorded under that component's + // subtree. + if got := strings.Count(api, "--component api"); got < 2 { + t.Errorf("api hotfix workflow emitted --component api %d times, want >= 2 (plan and finalize)", got) + } + if strings.Contains(api, "--component web") { + t.Errorf("api hotfix workflow must not carry web's --component (isolation)") + } + if !strings.Contains(web, "--component web") { + t.Errorf("web hotfix workflow missing --component web") + } + + // The context job's raw manifest read of the rollback (N-1) SHA must resolve + // the component's own state subtree, not the flat state. node. + if !strings.Contains(api, "state.components.api.") { + t.Errorf("api hotfix context job must read state.components.api.") + } + if strings.Contains(api, "state.components.web.") { + t.Errorf("api hotfix context job must not read web's state subtree (isolation)") + } + + // Concurrency isolation: each component's group carries its own identity and + // no two components share a lane. + apiGroup := concurrencyGroupLine(t, api) + webGroup := concurrencyGroupLine(t, web) + if apiGroup == webGroup { + t.Errorf("api and web hotfix concurrency groups must differ; both are %q", apiGroup) + } + if !strings.Contains(apiGroup, "api") { + t.Errorf("api hotfix concurrency group must carry the component identity: %q", apiGroup) + } + if strings.Contains(apiGroup, "web") { + t.Errorf("api hotfix concurrency group must not carry web's identity: %q", apiGroup) + } +} + +// TestRollbackTargets_SingleComponent_ByteIdentical proves a manifest with no +// components: block takes the untouched single-generator path, byte-identical to +// a directly built rollback generator, with no namespaced name and no --component. +func TestRollbackTargets_SingleComponent_ByteIdentical(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + + targets, err := rollbackTargets(cfg, "") + if err != nil { + t.Fatalf("rollbackTargets: %v", err) + } + if len(targets) != 1 { + t.Fatalf("got %d targets, want 1", len(targets)) + } + if targets[0].Path != ".github/workflows/cascade-rollback.yaml" { + t.Errorf("path = %q, want .github/workflows/cascade-rollback.yaml", targets[0].Path) + } + + got, err := targets[0].Gen.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + want, err := NewRollbackGenerator(cfg, "").Generate() + if err != nil { + t.Fatalf("baseline Generate: %v", err) + } + if got != want { + t.Errorf("single-component rollback output drifted from baseline generator") + } + if strings.Contains(got, "name: Rollback (") { + t.Errorf("single-component rollback output must not carry a component-namespaced name") + } + if strings.Contains(got, "--component") { + t.Errorf("single-component rollback output must not carry a --component flag") + } +} + +// TestRollbackTargets_Components_FanOut proves a components: manifest fans out one +// cascade-rollback-.yaml per component (sorted), no repo-wide +// cascade-rollback.yaml, each namespaced, each scoped with its own --component. +func TestRollbackTargets_Components_FanOut(t *testing.T) { + cfg := lifecycleMultiComponentConfig() + + targets, err := rollbackTargets(cfg, "") + if err != nil { + t.Fatalf("rollbackTargets: %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/cascade-rollback-api.yaml"] + if !ok { + t.Fatalf("missing cascade-rollback-api.yaml; got paths %v", keys(byPath)) + } + web, ok := byPath[".github/workflows/cascade-rollback-web.yaml"] + if !ok { + t.Fatalf("missing cascade-rollback-web.yaml; got paths %v", keys(byPath)) + } + if _, ok := byPath[".github/workflows/cascade-rollback.yaml"]; ok { + t.Errorf("repo-wide cascade-rollback.yaml must not be emitted when components are declared") + } + + if !strings.Contains(api, "name: Rollback (api)") { + t.Errorf("api rollback workflow missing namespaced name") + } + if !strings.Contains(web, "name: Rollback (web)") { + t.Errorf("web rollback workflow missing namespaced name") + } + + // Component scoping on both the preflight and finalize CLI invocations. + if got := strings.Count(api, "--component api"); got < 2 { + t.Errorf("api rollback workflow emitted --component api %d times, want >= 2 (preflight and finalize)", got) + } + if strings.Contains(api, "--component web") { + t.Errorf("api rollback workflow must not carry web's --component (isolation)") + } + if !strings.Contains(web, "--component web") { + t.Errorf("web rollback workflow missing --component web") + } + + // Concurrency isolation: the default per-workflow group differs by the + // component-namespaced workflow name, so no two components share a lane. + apiGroup := concurrencyGroupLine(t, api) + webGroup := concurrencyGroupLine(t, web) + if apiGroup == webGroup { + t.Errorf("api and web rollback concurrency groups must differ; both are %q", apiGroup) + } +} + +// TestRollbackTargets_GlobalConcurrencyGroupDoesNotCollapse proves an explicit +// manifest-global concurrency.group does not collapse every component's rollback +// onto one literal lane: the component identity is composed into each group. +func TestRollbackTargets_GlobalConcurrencyGroupDoesNotCollapse(t *testing.T) { + cfg := lifecycleMultiComponentConfig() + cfg.Concurrency = &config.ConcurrencyConfig{Group: "shared-lane"} + + targets, err := rollbackTargets(cfg, "") + if err != nil { + t.Fatalf("rollbackTargets: %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/cascade-rollback-api.yaml"]) + webGroup := concurrencyGroupLine(t, byPath[".github/workflows/cascade-rollback-web.yaml"]) + + if apiGroup == " group: shared-lane" { + t.Errorf("api rollback 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) + } + if !strings.Contains(apiGroup, "api") { + t.Errorf("api rollback group must retain the component identity: %q", apiGroup) + } + if !strings.Contains(webGroup, "web") { + t.Errorf("web rollback group must retain the component identity: %q", webGroup) + } +} diff --git a/internal/generate/plan.go b/internal/generate/plan.go index c833e7b8..d0aeb8a8 100644 --- a/internal/generate/plan.go +++ b/internal/generate/plan.go @@ -168,22 +168,34 @@ func Plan(opts PlanOptions) ([]PlannedFile, error) { planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-merge-queue.yaml", Content: content}) } - // 6. hotfix -> .github/workflows/cascade-hotfix.yaml when enabled. - if gen := NewHotfixGenerator(cfg, baseDir); gen.Enabled() { - content, err = gen.Generate() + // 6. hotfix -> cascade-hotfix.yaml when enabled, or one path-scoped + // cascade-hotfix-.yaml per component when the manifest declares + // components:. + hfTargets, err := hotfixTargets(cfg, baseDir) + if err != nil { + return nil, err + } + for _, t := range hfTargets { + content, err = t.Gen.Generate() if err != nil { return nil, fmt.Errorf("generating hotfix workflow: %w", err) } - planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-hotfix.yaml", Content: content}) + planned = append(planned, PlannedFile{Path: t.Path, Content: content}) } - // 7. rollback -> .github/workflows/cascade-rollback.yaml when enabled. - if gen := NewRollbackGenerator(cfg, baseDir); gen.Enabled() { - content, err = gen.Generate() + // 7. rollback -> cascade-rollback.yaml when enabled, or one path-scoped + // cascade-rollback-.yaml per component when the manifest declares + // components:. + rbTargets, err := rollbackTargets(cfg, baseDir) + if err != nil { + return nil, err + } + for _, t := range rbTargets { + content, err = t.Gen.Generate() if err != nil { return nil, fmt.Errorf("generating rollback workflow: %w", err) } - planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-rollback.yaml", Content: content}) + planned = append(planned, PlannedFile{Path: t.Path, Content: content}) } // 8. pr-preview -> .github/workflows/cascade-pr-preview.yaml when enabled. @@ -349,6 +361,129 @@ func promoteComponentWorkflowPath(outputPath, name string) string { return filepath.Join(filepath.Dir(outputPath), fmt.Sprintf("promote-%s.yaml", name)) } +// hotfixWorkflowPath and rollbackWorkflowPath are the canonical single-component +// output locations for the hotfix and rollback workflows. The generate command +// and Plan both anchor their fan-out on these so the two never disagree. +const ( + hotfixWorkflowPath = ".github/workflows/cascade-hotfix.yaml" + rollbackWorkflowPath = ".github/workflows/cascade-rollback.yaml" +) + +// hotfixTarget pairs a rendered hotfix workflow's target path with the generator +// that produces it, mirroring promoteTarget so the generate command and Plan share +// one fan-out decision for the hotfix surface. +type hotfixTarget struct { + Path string + Gen *HotfixGenerator +} + +// hotfixTargets returns the hotfix workflow target(s) the manifest emits, +// mirroring promoteTargets. A manifest with no components: block yields exactly +// one target at the canonical path (byte-identical to the pre-component +// generator), or no targets when the single-component hotfix is not enabled +// (fewer than two environments). A manifest that declares components yields one +// cascade-hotfix-.yaml per component whose resolved config enables the +// hotfix workflow (sorted by name) and no repo-wide hotfix file, each generated +// from the resolved per-component config and scoped with --component so plan and +// finalize record state under that component's subtree. Both the generate command +// and Plan call this, so they can never disagree on the hotfix file set. +func hotfixTargets(cfg *config.TrunkConfig, baseDir string) ([]hotfixTarget, error) { + if !cfg.HasComponents() { + gen := NewHotfixGenerator(cfg, baseDir) + if !gen.Enabled() { + return nil, nil + } + return []hotfixTarget{{Path: hotfixWorkflowPath, Gen: gen}}, nil + } + + names := make([]string, 0, len(cfg.Components)) + for name := range cfg.Components { + names = append(names, name) + } + sort.Strings(names) + + targets := make([]hotfixTarget, 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 := NewHotfixGenerator(resolved.Config, baseDir, WithHotfixComponentName(name)) + if !gen.Enabled() { + continue + } + targets = append(targets, hotfixTarget{Path: hotfixComponentWorkflowPath(name), Gen: gen}) + } + return targets, nil +} + +// hotfixComponentWorkflowPath derives a per-component hotfix workflow path from the +// canonical output path: the base directory plus cascade-hotfix-.yaml. +func hotfixComponentWorkflowPath(name string) string { + return filepath.Join(filepath.Dir(hotfixWorkflowPath), fmt.Sprintf("cascade-hotfix-%s.yaml", name)) +} + +// rollbackTarget pairs a rendered rollback workflow's target path with the +// generator that produces it, mirroring promoteTarget. +type rollbackTarget struct { + Path string + Gen *RollbackGenerator +} + +// rollbackTargets returns the rollback workflow target(s) the manifest emits, +// mirroring promoteTargets. A manifest with no components: block yields exactly +// one target at the canonical path (byte-identical to the pre-component +// generator), or no targets when the single-component rollback is not enabled. A +// manifest that declares components yields one cascade-rollback-.yaml per +// component whose resolved config enables the rollback workflow (sorted by name) +// and no repo-wide rollback file, each generated from the resolved per-component +// config and scoped with --component. The manifest-global concurrency.group is +// captured before resolution so the per-component rollback group can compose it +// instead of collapsing onto it. Both the generate command and Plan call this, so +// they can never disagree on the rollback file set. +func rollbackTargets(cfg *config.TrunkConfig, baseDir string) ([]rollbackTarget, error) { + if !cfg.HasComponents() { + gen := NewRollbackGenerator(cfg, baseDir) + if !gen.Enabled() { + return nil, nil + } + return []rollbackTarget{{Path: rollbackWorkflowPath, 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([]rollbackTarget, 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 := NewRollbackGenerator(resolved.Config, baseDir, + WithRollbackComponentName(name), + WithRollbackGlobalConcurrencyGroup(globalGroup)) + if !gen.Enabled() { + continue + } + targets = append(targets, rollbackTarget{Path: rollbackComponentWorkflowPath(name), Gen: gen}) + } + return targets, nil +} + +// rollbackComponentWorkflowPath derives a per-component rollback workflow path from +// the canonical output path: the base directory plus cascade-rollback-.yaml. +func rollbackComponentWorkflowPath(name string) string { + return filepath.Join(filepath.Dir(rollbackWorkflowPath), fmt.Sprintf("cascade-rollback-%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/rollback.go b/internal/generate/rollback.go index 06a46d77..23527904 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -21,15 +21,68 @@ import ( type RollbackGenerator struct { config *config.TrunkConfig baseDir string + + // componentName, when non-empty, names the component this rollback workflow is + // scoped to. It suffixes the workflow name, threads --component through the + // rollback CLI steps so preflight and finalize read and record state under this + // component's subtree, and switches the concurrency group into a + // rollback-namespaced per-component lane. It is set only via + // WithRollbackComponentName by the per-component fan-out. + componentName string + + // globalConcurrencyGroup is the manifest-global concurrency.group as declared + // on the shared top-level config, captured before per-component resolution + // overwrites it. ResolveComponent rewrites the resolved config's group to the + // orchestrate lane, so component mode cannot honor it bare; instead this + // captured value is composed with the rollback-namespaced per-component group + // so a shared global group scopes per component rather than collapsing every + // component's rollback onto one lane. It is set only via + // WithRollbackGlobalConcurrencyGroup. + globalConcurrencyGroup string +} + +// RollbackGeneratorOption configures a RollbackGenerator. Options are additive so +// new per-component capability never breaks the positional constructor signature. +type RollbackGeneratorOption func(*RollbackGenerator) + +// WithRollbackComponentName scopes the generated rollback workflow to a declared +// component so a multi-component manifest emits one distinct +// cascade-rollback-.yaml per component. It sets the emitted workflow name, +// threads --component through the rollback CLI steps, and selects the +// rollback-namespaced per-component concurrency group. +func WithRollbackComponentName(name string) RollbackGeneratorOption { + return func(g *RollbackGenerator) { g.componentName = name } +} + +// WithRollbackGlobalConcurrencyGroup records the manifest-global concurrency.group +// before per-component resolution overwrites it, so component-mode writeConcurrency +// can compose it with the component identity instead of honoring the resolved +// (orchestrate-namespaced) group or collapsing every component onto one bare lane. +func WithRollbackGlobalConcurrencyGroup(group string) RollbackGeneratorOption { + return func(g *RollbackGenerator) { g.globalConcurrencyGroup = group } } // NewRollbackGenerator creates a rollback-workflow generator bound to the given // trunk config and repository base directory. -func NewRollbackGenerator(cfg *config.TrunkConfig, baseDir string) *RollbackGenerator { - return &RollbackGenerator{ +func NewRollbackGenerator(cfg *config.TrunkConfig, baseDir string, opts ...RollbackGeneratorOption) *RollbackGenerator { + g := &RollbackGenerator{ config: cfg, baseDir: baseDir, } + for _, opt := range opts { + opt(g) + } + return g +} + +// writeComponentFlag emits a "--component \" continuation line at the given +// indent when this rollback workflow is scoped to a component, so the rollback CLI +// reads and records state under that component's subtree. The single-component +// workflow emits nothing, keeping its CLI invocations byte-identical. +func (g *RollbackGenerator) writeComponentFlag(sb *strings.Builder, indent string) { + if g.componentName != "" { + fmt.Fprintf(sb, "%s--component %s \\\n", indent, g.componentName) + } } // Enabled reports whether the rollback workflow should be emitted. It requires @@ -126,7 +179,11 @@ func (g *RollbackGenerator) writeHeader(sb *strings.Builder) { } func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) { - sb.WriteString("name: Rollback\n\n") + if g.componentName != "" { + fmt.Fprintf(sb, "name: Rollback (%s)\n\n", g.componentName) + } else { + sb.WriteString("name: Rollback\n\n") + } sb.WriteString("on:\n") sb.WriteString(" workflow_dispatch:\n") sb.WriteString(" inputs:\n") @@ -211,6 +268,28 @@ func (g *RollbackGenerator) writeRepositoryDispatchTrigger(sb *strings.Builder) // overrides it, mirroring the promote generator. func (g *RollbackGenerator) writeConcurrency(sb *strings.Builder) { sb.WriteString("concurrency:\n") + + // Component mode: emit a rollback-namespaced per-component group and ignore the + // resolved config's group entirely. ResolveComponent rewrites the resolved + // group to the orchestrate lane, so honoring it would serialize this + // component's rollback against its own orchestrate run (a repo-global lane + // collision); a manifest-global concurrency.group emitted bare would collapse + // every component's rollback 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 or promote namespaces. cancel-in-progress + // stays false: rollback mutates durable env state, so queueing is safer than + // cancelling a mid-flight run. + if g.componentName != "" { + group := config.RollbackConcurrencyGroup(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 { @@ -270,6 +349,7 @@ func (g *RollbackGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" run: |\n") sb.WriteString(" cascade rollback preflight \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + g.writeComponentFlag(sb, " ") sb.WriteString(" --env \"$ENVIRONMENT\" \\\n") sb.WriteString(" --to \"$TARGET\" \\\n") sb.WriteString(" --deployable \"$DEPLOYABLE\" \\\n") @@ -394,6 +474,7 @@ func (g *RollbackGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" run: |\n") sb.WriteString(" cascade rollback finalize \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + g.writeComponentFlag(sb, " ") sb.WriteString(" --env \"${{ needs.preflight.outputs.target_env }}\" \\\n") sb.WriteString(" --to \"${{ needs.preflight.outputs.target_sha }}\" \\\n") sb.WriteString(" --deployable \"$DEPLOYABLE\" \\\n")