Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions e2e/scenarios/53-component-hotfix-rollback-fanout.yaml
Original file line number Diff line number Diff line change
@@ -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-<name>.yaml per
component (cascade-hotfix-api.yaml, cascade-hotfix-web.yaml) and the rollback lane
out to one cascade-rollback-<name>.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.<name>.<env>) 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-<name>)
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-<name>.yaml
# runs the hotfix CLI with --component <name> (the flag that scopes plan and
# finalize state to that component's subtree at runtime), reads its own N-1 state
# under state.components.<name> 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-<name>.yaml runs the rollback CLI (preflight and finalize) with
# --component <name> and carries a rollback-namespaced concurrency group
# (rollback-<name>). 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
14 changes: 14 additions & 0 deletions internal/config/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 29 additions & 20 deletions internal/generate/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>.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-<name>.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)
}
}

Expand Down
67 changes: 62 additions & 5 deletions internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>. 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-<name>.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 <name> \" 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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -560,7 +608,15 @@ func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) {
// ".$MANIFEST_KEY.state.<env>.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.<name>.<env>; the
// single-component form is the flat state.<env>. 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")
Expand Down Expand Up @@ -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")
Expand Down
Loading