From d2bd4ec80a2edfc8ac599aaf61ff8976706fb5f4 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 8 Jul 2026 02:40:39 -0400 Subject: [PATCH] feat(e2e): execute per-component promote workflows and assert component-scoped state The harness can now target a specific component's generated workflow and assert its recorded state. A promote or orchestrate step takes an optional component: an empty component runs the flat promote.yaml/orchestrate.yaml and asserts state. exactly as before (byte-identical); a named component runs promote-.yaml/orchestrate-.yaml and asserts state.components... State sync parses the components subtree into composite keys so a component-scoped expectation reads the owning node. A new scenario seeds and promotes one component through the ladder, asserts a sibling component's recorded state stays byte-unchanged across that cycle, then advances the sibling independently, proving per-component promotion isolation end-to-end in the act and gitea run. Refs #290, #291. Signed-off-by: Joshua Temple --- e2e/harness/component_promote_test.go | 198 ++++++++++++++++++ e2e/harness/multistep.go | 41 +++- e2e/harness/runner.go | 161 ++++++++++++-- .../51-component-promote-isolation.yaml | 118 +++++++++++ 4 files changed, 499 insertions(+), 19 deletions(-) create mode 100644 e2e/harness/component_promote_test.go create mode 100644 e2e/scenarios/51-component-promote-isolation.yaml diff --git a/e2e/harness/component_promote_test.go b/e2e/harness/component_promote_test.go new file mode 100644 index 00000000..f4ab58e3 --- /dev/null +++ b/e2e/harness/component_promote_test.go @@ -0,0 +1,198 @@ +package harness + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPromoteWorkflowPath verifies the promote workflow path selection: an empty +// component keeps the repo-wide promote.yaml (byte-identical single-component +// behavior), while a named component targets its fanned-out promote-.yaml. +func TestPromoteWorkflowPath(t *testing.T) { + cases := []struct { + name string + component string + want string + }{ + {name: "single component keeps repo-wide file", component: "", want: ".github/workflows/promote.yaml"}, + {name: "named component selects fanned-out file", component: "api", want: ".github/workflows/promote-api.yaml"}, + {name: "second component selects its own file", component: "web", want: ".github/workflows/promote-web.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, promoteWorkflowPath(tc.component)) + }) + } +} + +// TestOrchestrateWorkflowPath verifies the orchestrate workflow path selection +// mirrors promote: empty keeps orchestrate.yaml, a component selects its +// orchestrate-.yaml. +func TestOrchestrateWorkflowPath(t *testing.T) { + cases := []struct { + name string + component string + want string + }{ + {name: "single component keeps repo-wide file", component: "", want: ".github/workflows/orchestrate.yaml"}, + {name: "named component selects fanned-out file", component: "api", want: ".github/workflows/orchestrate-api.yaml"}, + {name: "second component selects its own file", component: "web", want: ".github/workflows/orchestrate-web.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, orchestrateWorkflowPath(tc.component)) + }) + } +} + +// TestComponentStateKey verifies the composite key is distinct per (component, env) +// and never collides with a flat env key. +func TestComponentStateKey(t *testing.T) { + assert.Equal(t, "components/api/dev", componentStateKey("api", "dev")) + assert.Equal(t, "components/api/prod", componentStateKey("api", "prod")) + assert.Equal(t, "components/web/prod", componentStateKey("web", "prod")) + // Distinct components at the same env must not collide. + assert.NotEqual(t, componentStateKey("api", "prod"), componentStateKey("web", "prod")) + // The composite key is never a bare env name, so a component-scoped record can + // never overwrite a flat state. record. + assert.NotEqual(t, "prod", componentStateKey("api", "prod")) +} + +// TestParseComponentStates parses a manifest carrying both flat env rows and a +// per-component subtree, asserting only the component rows are returned and the +// flat rows are ignored (they are handled by the flat parse). +func TestParseComponentStates(t *testing.T) { + manifest := `ci: + config: + trunk_branch: main + state: + dev: + sha: flatdevsha + version: v0.1.0-rc.0 + components: + api: + dev: + sha: apidevsha + version: api-v0.1.0-rc.0 + prod: + sha: apiprodsha + version: api-v0.1.0 + deploys: + app: + sha: apideploysha + web: + dev: + sha: webdevsha + version: web-v0.1.0-rc.0 +` + components, err := parseComponentStates(manifest, "ci") + require.NoError(t, err) + require.Len(t, components, 2) + + assert.Equal(t, "apidevsha", components["api"]["dev"].SHA) + assert.Equal(t, "api-v0.1.0-rc.0", components["api"]["dev"].Version) + assert.Equal(t, "apiprodsha", components["api"]["prod"].SHA) + assert.Equal(t, "api-v0.1.0", components["api"]["prod"].Version) + assert.Equal(t, "apideploysha", components["api"]["prod"].Deploys["app"].SHA) + assert.Equal(t, "webdevsha", components["web"]["dev"].SHA) + + // The flat "dev" row is not surfaced as a component. + _, ok := components["dev"] + assert.False(t, ok, "flat env row must not be parsed as a component") +} + +// TestParseComponentStates_NoComponents confirms a flat manifest yields an empty +// component map and no error, so a single-component scenario's readback is a no-op. +func TestParseComponentStates_NoComponents(t *testing.T) { + manifest := `ci: + state: + dev: + sha: devsha + version: v0.1.0-rc.0 + prod: + sha: prodsha + version: v0.1.0 +` + components, err := parseComponentStates(manifest, "ci") + require.NoError(t, err) + assert.Empty(t, components) +} + +// TestParseComponentStates_MissingKey confirms a manifest without the requested +// top-level key yields an empty map rather than an error. +func TestParseComponentStates_MissingKey(t *testing.T) { + components, err := parseComponentStates("other:\n state: {}\n", "ci") + require.NoError(t, err) + assert.Empty(t, components) +} + +// TestRunner_AssertStep_ComponentState_Isolation drives the component-scoped +// assertion branch: with only component A's prod subtree recorded, an expectation +// that A.prod advanced AND B.prod is absent (wiped) must pass, proving the +// component-scoped lookup targets the composite key rather than the flat env. +func TestRunner_AssertStep_ComponentState_Isolation(t *testing.T) { + runner := &Runner{ctx: NewExecutionContext(), t: t} + runner.ctx.RecordCommit("commit1", "abc123") + // Only component A advanced to prod; component B has no prod row. + runner.ctx.RecordState(componentStateKey("api", "prod"), "abc123", "api-v0.1.0") + + step := &Step{ + Name: "assert per-component isolation", + Action: "promote", + Expect: &StepExpect{ + State: map[string]*StateExpect{ + // A advanced at its own prod subtree. + "api-prod": {Component: "api", Env: "prod", SHA: "commit1", Version: "api-v0.1.0"}, + // B's prod subtree is untouched (absent). A distinct map key plus an + // explicit env lets both components be asserted at the same env in one + // step, which an env-keyed map alone cannot express. + "web-prod": {Component: "web", Env: "prod", Wiped: true}, + }, + }, + } + + ctx := context.Background() + preState := runner.ctx.Clone() + errs := runner.assertStep(ctx, step, preState) + assert.Empty(t, errs) + + // Recording B's prod subtree must now break the "wiped" expectation, proving + // the sibling assertion is really scoped to component B and not component A. + runner.ctx.RecordState(componentStateKey("web", "prod"), "def456", "web-v0.1.0") + errs = runner.assertStep(ctx, step, preState) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "components/web/prod") +} + +// TestRunner_AssertStep_ComponentState_Unchanged verifies the "unchanged" +// expectation resolves the component composite key: component B's dev subtree must +// read as unchanged across a step that only advanced component A. +func TestRunner_AssertStep_ComponentState_Unchanged(t *testing.T) { + runner := &Runner{ctx: NewExecutionContext(), t: t} + runner.ctx.RecordState(componentStateKey("web", "dev"), "webdev", "web-v0.1.0-rc.0") + + preState := runner.ctx.Clone() + + step := &Step{ + Name: "assert sibling unchanged", + Action: "promote", + Expect: &StepExpect{ + State: map[string]*StateExpect{ + "dev": {Component: "web", Unchanged: true}, + }, + }, + } + + ctx := context.Background() + errs := runner.assertStep(ctx, step, preState) + assert.Empty(t, errs) + + // Mutating component B's dev subtree must now trip the unchanged assertion. + runner.ctx.RecordState(componentStateKey("web", "dev"), "webdev2", "web-v0.1.0-rc.1") + errs = runner.assertStep(ctx, step, preState) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "components/web/dev") +} diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 7699edd2..ca5ab9b6 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -69,6 +69,12 @@ type Step struct { Action string `yaml:"action"` // commit, orchestrate, promote, hotfix_plan, hotfix_apply, merge_pr, resolve_conflict, hotfix_merged Commit *CommitStep `yaml:"commit,omitempty"` Promote *PromoteStep `yaml:"promote,omitempty"` + // Orchestrate optionally configures an "orchestrate" action. It is not + // required: an orchestrate step with no config runs the repo-wide + // orchestrate.yaml exactly as before. It exists so a component: manifest, + // whose orchestrate lane is fanned out into one orchestrate-.yaml per + // component, can seed a specific component's version line independently. + Orchestrate *OrchestrateStep `yaml:"orchestrate,omitempty"` // Rollback configures a "rollback" action: a workflow_dispatch run of the // cascade-rollback workflow that re-points an environment at a prior version // or SHA, re-runs its deploys at that target, and marks the environment @@ -125,10 +131,10 @@ type Step struct { // workflow's plan job. CommitRef is the trunk commit to plan a hotfix for and is // resolved via the execution context (falling back to a literal SHA). type HotfixPlanStep struct { - CommitRef string `yaml:"commit_ref"` - TargetEnv string `yaml:"target_env"` - DryRun bool `yaml:"dry_run,omitempty"` - ExpectFailure bool `yaml:"expect_failure,omitempty"` + CommitRef string `yaml:"commit_ref"` + TargetEnv string `yaml:"target_env"` + DryRun bool `yaml:"dry_run,omitempty"` + ExpectFailure bool `yaml:"expect_failure,omitempty"` // AssertBranchReset, when true, asserts that the plan workflow logged the // orphan self-heal diagnostic line (branch_reset=true), confirming the heal // fired rather than the plan merely succeeding for another reason. @@ -192,9 +198,25 @@ type CommitStep struct { Files map[string]string `yaml:"files"` } +// OrchestrateStep configures an "orchestrate" action. Component, when set, +// targets the per-component orchestrate workflow +// .github/workflows/orchestrate-.yaml (emitted for a manifest with a +// components: block) instead of the repo-wide orchestrate.yaml, so a scenario can +// seed one component's version line without touching a sibling. Empty selects the +// repo-wide orchestrate.yaml, byte-identical to an orchestrate step with no config. +type OrchestrateStep struct { + Component string `yaml:"component,omitempty"` +} + // PromoteStep defines a promote action type PromoteStep struct { Mode string `yaml:"mode"` // default, cascade + // Component, when set, targets the per-component promote workflow + // .github/workflows/promote-.yaml (emitted for a manifest with a + // components: block) instead of the repo-wide promote.yaml, and that + // component's promotion records state under state.components... + // Empty selects the single-component promote.yaml, byte-identical to today. + Component string `yaml:"component,omitempty"` // Target is the destination env for a cascade promote; the harness builds the // "-to-" mode string from Source and Target. Target string `yaml:"target,omitempty"` @@ -379,6 +401,17 @@ type WorkflowFileExpect struct { // StateExpect defines expected state for an environment type StateExpect struct { + // Component, when set, scopes this expectation to a declared component's state + // subtree at state.components.. rather than the flat + // state.. It is how a scenario asserts per-component promotion isolation: + // component A advancing must leave component B's subtree byte-intact. + Component string `yaml:"component,omitempty"` + // Env names the environment within the component subtree when Component is set. + // When empty it defaults to the map key this expectation is filed under, so a + // component-free scenario (the common case) never sets it and is unaffected. + // It exists only to let a single step assert two components at the SAME env, + // which the env-keyed map alone cannot express. + Env string `yaml:"env,omitempty"` SHA string `yaml:"sha,omitempty"` // Can be "commit1", "commit2", etc. Version string `yaml:"version,omitempty"` Wiped bool `yaml:"wiped,omitempty"` // State should not exist diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 7169a2b4..efc895da 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -370,7 +370,7 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err case "commit": return r.executeCommit(ctx, step.Commit) case "orchestrate": - return r.executeOrchestrate(ctx, config, step.ExpectFailure) + return r.executeOrchestrate(ctx, config, step.ExpectFailure, step.Orchestrate) case "promote": return r.executePromote(ctx, step.Promote, config) case "hotfix_plan": @@ -675,6 +675,69 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } +// orchestrateWorkflowPath returns the repo-relative orchestrate workflow path for +// a component. An empty component selects the repo-wide orchestrate.yaml; a named +// component selects the fanned-out orchestrate-.yaml the generator emits for +// a manifest with a components: block. +func orchestrateWorkflowPath(component string) string { + if component == "" { + return ".github/workflows/orchestrate.yaml" + } + return fmt.Sprintf(".github/workflows/orchestrate-%s.yaml", component) +} + +// promoteWorkflowPath returns the repo-relative promote workflow path for a +// component. An empty component selects the repo-wide promote.yaml; a named +// component selects the fanned-out promote-.yaml the generator emits for a +// manifest with a components: block. +func promoteWorkflowPath(component string) string { + if component == "" { + return ".github/workflows/promote.yaml" + } + return fmt.Sprintf(".github/workflows/promote-%s.yaml", component) +} + +// componentStateKey composes the ExecutionContext key under which a component's +// per-environment state is recorded. Component-scoped state lives at +// state.components.. in the manifest; the harness records it under +// this composite key so the flat state. path is untouched and every existing +// state helper (record/get/clone/unchanged) keeps working without change. +func componentStateKey(component, env string) string { + return "components/" + component + "/" + env +} + +// componentEnvStateYAML is the subset of a component's per-env manifest row the +// harness reads back. It mirrors the flat env row parsed in syncStateFromGitea. +type componentEnvStateYAML struct { + SHA string `yaml:"sha"` + Version string `yaml:"version"` + Deploys map[string]struct { + SHA string `yaml:"sha"` + } `yaml:"deploys"` +} + +// parseComponentStates extracts ci.state.components.. rows from a +// manifest document under manifestKey (typically config.DefaultManifestKey). A +// manifest with no components subtree yields an empty map and no error. It is a +// pure function so the component-scoped readback is unit-testable without a live +// gitea/harness. The flat state. rows alongside components are ignored here: +// they are read by the existing flat parse in syncStateFromGitea. +func parseComponentStates(manifestContent, manifestKey string) (map[string]map[string]componentEnvStateYAML, error) { + var doc map[string]struct { + State struct { + Components map[string]map[string]componentEnvStateYAML `yaml:"components"` + } `yaml:"state"` + } + if err := yaml.Unmarshal([]byte(manifestContent), &doc); err != nil { + return nil, err + } + section, ok := doc[manifestKey] + if !ok { + return nil, nil + } + return section.State.Components, nil +} + // consistencyReport mirrors the JSON shape printed by `cascade status // consistency --json`. Only the fields the harness asserts on are modeled. type consistencyReport struct { @@ -902,19 +965,32 @@ func (r *Runner) executeStageDivergence(ctx context.Context, step *StageDivergen // executeOrchestrate runs the orchestrate workflow via ActRunner. When // expectFailure is set, a failure conclusion is the success path (mirrors // executePromote's ExpectFailure handling) and a success conclusion is an error. -func (r *Runner) executeOrchestrate(ctx context.Context, config Config, expectFailure bool) error { +// When orch names a component, it runs that component's fanned-out +// orchestrate-.yaml instead of the repo-wide orchestrate.yaml, so a +// components: manifest can seed one component's version line independently. +func (r *Runner) executeOrchestrate(ctx context.Context, config Config, expectFailure bool, orch *OrchestrateStep) error { if r.harness == nil || r.harness.act == nil { r.t.Log(" Would execute orchestrate workflow (no harness)") return nil } + component := "" + if orch != nil { + component = orch.Component + } + workflowPath := orchestrateWorkflowPath(component) + // Get the current HEAD SHA for later reference sha, err := r.harness.gitea.getHeadSHA(ctx, r.harness.repo) if err != nil { return fmt.Errorf("failed to get HEAD SHA: %w", err) } - r.t.Logf(" Orchestrate: running workflow for SHA %s", truncateSHA(sha)) + if component != "" { + r.t.Logf(" Orchestrate: running %s for SHA %s", workflowPath, truncateSHA(sha)) + } else { + r.t.Logf(" Orchestrate: running workflow for SHA %s", truncateSHA(sha)) + } // Sync the repo to act container before running workflow if err := r.harness.SyncRepoToActContainer(ctx); err != nil { @@ -922,7 +998,7 @@ func (r *Runner) executeOrchestrate(ctx context.Context, config Config, expectFa } // Debug: check what's in /tmp/repo - debugCmd := []string{"bash", "-c", "cd /tmp/repo && git branch -a && ls -la .github/actions/ && ls -la .github/actions/setup-cli/ && cat .github/workflows/orchestrate.yaml | head -50"} + debugCmd := []string{"bash", "-c", "cd /tmp/repo && git branch -a && ls -la .github/actions/ && ls -la .github/actions/setup-cli/ && cat " + workflowPath + " | head -50"} _, debugReader, _ := r.harness.act.Container().Exec(ctx, debugCmd) if debugReader != nil { var debugOut bytes.Buffer @@ -938,7 +1014,7 @@ func (r *Runner) executeOrchestrate(ctx context.Context, config Config, expectFa // Run the actual orchestrate workflow via ActRunner result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ - WorkflowPath: ".github/workflows/orchestrate.yaml", + WorkflowPath: workflowPath, Event: "push", Env: map[string]string{ "GITHUB_SHA": sha, @@ -999,15 +1075,25 @@ func (r *Runner) executePromote(ctx context.Context, promote *PromoteStep, confi return nil } - r.t.Logf(" Promote: running workflow (mode=%s, target=%s)", promote.Mode, promote.Target) + // Select the promote workflow. A component-scoped step runs that component's + // fanned-out promote-.yaml (emitted for a components: manifest); the + // single-component default is the repo-wide promote.yaml, byte-identical to + // before. The workflow's dispatch inputs (mode/force/...) are identical across + // both shapes, so only the path changes here. + workflowPath := promoteWorkflowPath(promote.Component) + + r.t.Logf(" Promote: running %s (mode=%s, target=%s, component=%s)", + workflowPath, promote.Mode, promote.Target, promote.Component) // Sync the repo to act container before running workflow if err := r.harness.SyncRepoToActContainer(ctx); err != nil { return fmt.Errorf("failed to sync repo: %w", err) } - // Debug: Check if promote.yaml exists - debugCmd := []string{"bash", "-c", "ls -la /tmp/repo/.github/workflows/ && head -30 /tmp/repo/.github/workflows/promote.yaml 2>&1 || echo 'promote.yaml not found'"} + // Debug: Check if the selected promote workflow exists + debugCmd := []string{"bash", "-c", fmt.Sprintf( + "ls -la /tmp/repo/.github/workflows/ && head -30 /tmp/repo/%s 2>&1 || echo '%s not found'", + workflowPath, workflowPath)} _, debugReader, _ := r.harness.act.Container().Exec(ctx, debugCmd) if debugReader != nil { var debugOut bytes.Buffer @@ -1087,7 +1173,7 @@ func (r *Runner) executePromote(ctx context.Context, promote *PromoteStep, confi // Run the actual promote workflow via ActRunner result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ - WorkflowPath: ".github/workflows/promote.yaml", + WorkflowPath: workflowPath, Event: "workflow_dispatch", Inputs: inputs, Env: map[string]string{ @@ -1241,6 +1327,12 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { r.ctx.ClearState() for env, state := range ciData.State { + // "components" is not an environment: it is the per-component subtree + // (state.components..), read separately below via + // parseComponentStates. Skip it here so it is not recorded as a junk env. + if env == "components" { + continue + } r.ctx.RecordState(env, state.SHA, state.Version) r.t.Logf(" Synced state[%s] = %s @ %s", env, truncateSHA(state.SHA), state.Version) @@ -1270,6 +1362,32 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { r.t.Logf(" Synced state[release] (from latest_release) = %s @ %s", truncateSHA(lr.SHA), lr.Version) } + // Read component-scoped state (state.components..) written by a + // per-component promote finalize, recording each row under a composite key so + // component-scoped assertions can observe that one component advanced while a + // sibling's subtree stayed byte-intact. ClearState above already dropped any + // prior composite keys, so wiped/unchanged assertions see a faithful rebuild. + // The manifest key is "ci" by default, matching the flat parse above (the + // config parameter shadows the config package here, so use the literal). + components, err := parseComponentStates(manifestContent, "ci") + if err != nil { + r.t.Logf(" Note: could not parse component state: %v", err) + return nil + } + for comp, envs := range components { + for env, st := range envs { + key := componentStateKey(comp, env) + r.ctx.RecordState(key, st.SHA, st.Version) + r.t.Logf(" Synced state.components[%s][%s] = %s @ %s", + comp, env, truncateSHA(st.SHA), st.Version) + for deployName, deployState := range st.Deploys { + r.ctx.RecordDeployState(key, deployName, deployState.SHA) + r.t.Logf(" Synced state.components[%s][%s].deploys[%s] = %s", + comp, env, deployName, truncateSHA(deployState.SHA)) + } + } + } + return nil } @@ -1333,19 +1451,32 @@ func (r *Runner) assertStep(ctx context.Context, step *Step, preState *Execution var allErrs []error expect := step.Expect - // Assert state - for env, stateExpect := range expect.State { + // Assert state. The map key is the flat env for a single-component scenario; + // when the expectation names a Component, the lookup is redirected to that + // component's composite key (state.components..), where env + // defaults to the map key unless an explicit Env disambiguates two components + // asserted at the same env in one step. + for key, stateExpect := range expect.State { + lookupKey := key + if stateExpect.Component != "" { + env := stateExpect.Env + if env == "" { + env = key + } + lookupKey = componentStateKey(stateExpect.Component, env) + } + // Handle "unchanged" expectation if stateExpect.Unchanged { - preEnvState := preState.GetState(env) - currentState := r.ctx.GetState(env) + preEnvState := preState.GetState(lookupKey) + currentState := r.ctx.GetState(lookupKey) if preEnvState.SHA != currentState.SHA || preEnvState.Version != currentState.Version { allErrs = append(allErrs, fmt.Errorf("state[%s] expected unchanged but changed from %s/%s to %s/%s", - env, preEnvState.SHA, preEnvState.Version, currentState.SHA, currentState.Version)) + lookupKey, preEnvState.SHA, preEnvState.Version, currentState.SHA, currentState.Version)) } continue } - errs := AssertState(r.ctx, env, stateExpect) + errs := AssertState(r.ctx, lookupKey, stateExpect) allErrs = append(allErrs, errs...) } diff --git a/e2e/scenarios/51-component-promote-isolation.yaml b/e2e/scenarios/51-component-promote-isolation.yaml new file mode 100644 index 00000000..5abb432e --- /dev/null +++ b/e2e/scenarios/51-component-promote-isolation.yaml @@ -0,0 +1,118 @@ +name: "Per-Component Promotion Isolation" +description: | + Proves per-component promotion records state under only its own subtree and + never disturbs a sibling (#290, #292). The manifest declares two components (api, + web), each owning a path subtree with its own strict tag namespace, promoting + through the same dev to prod ladder on its own version line. Generation fans the + promote lane out to one promote-.yaml per component; this scenario executes + a specific component's promote workflow through act, which the generation-only + component scenarios could not yet do. + + Each component is seeded by its own orchestrate-.yaml and then promoted to + prod by its own promote-.yaml. api is advanced end to end first, then web + is advanced through its full seed-and-promote cycle. The proof is that api's + recorded prod state stays byte-identical (its own api-0.1.0 version line) across + web's entire orchestrate-and-promote cycle, and web lands its own web-0.1.0 line + under only its own subtree: neither promotion rebuilds, moves, or drops the + other's state.components. subtree. + +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: "feat: seed component sources" + files: + services/api/main.go: | + package main + + func main() {} + services/web/main.go: | + package main + + func main() {} + + # Confirm the multi-component generate then verify roundtrip is drift-free, so + # the per-component workflows executed below are the pristine generated output. + - name: "Regenerate the per-component set and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 + + # Cut api's dev prerelease on its own version line. + - name: "Orchestrate api to cut its dev prerelease" + action: orchestrate + orchestrate: + component: api + + # Promote api from dev to prod. Its promote-api.yaml runs the promotion CLI with + # --component api, so the published state lands only under state.components.api. + # web has not been touched yet, so its subtree must be absent. + - name: "Promote api from dev to prod" + action: promote + promote: + mode: cascade + target: prod + component: api + expect: + state: + api-prod: + component: api + env: prod + version: "api-0.1.0" + web-prod: + component: web + env: prod + wiped: true + + # Now advance web through its own full cycle. Cutting web's dev prerelease + # rewrites the shared flat dev row, but must not touch api's recorded subtree. + - name: "Orchestrate web to cut its dev prerelease" + action: orchestrate + orchestrate: + component: web + expect: + state: + api-prod: + component: api + env: prod + unchanged: true + + # Promote web from dev to prod. Its promote-web.yaml writes only + # state.components.web; api's prod subtree must survive byte-identical, on its own + # api-0.1.0 version line, distinct from web's web-0.1.0. + - name: "Promote web from dev to prod" + action: promote + promote: + mode: cascade + target: prod + component: web + expect: + state: + web-prod: + component: web + env: prod + version: "web-0.1.0" + api-prod: + component: api + env: prod + unchanged: true