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
94 changes: 94 additions & 0 deletions e2e/scenarios/50-component-promote-fanout.yaml
Original file line number Diff line number Diff line change
@@ -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-<name>.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-<name>) 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-<name>.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-<name>.yaml runs the
# promotion CLI with --component <name> (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
13 changes: 13 additions & 0 deletions internal/config/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 42 additions & 29 deletions internal/generate/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>.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)
}
}

Expand Down
83 changes: 76 additions & 7 deletions internal/generate/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>.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() {
Expand Down Expand Up @@ -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-<name>.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-<name>.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
Expand Down
Loading