diff --git a/e2e/harness/assert.go b/e2e/harness/assert.go index b5d87f95..c0aaced3 100644 --- a/e2e/harness/assert.go +++ b/e2e/harness/assert.go @@ -284,6 +284,15 @@ func AssertState(ctx *ExecutionContext, env string, expect *StateExpect) []error env, expect.PreviousVersion, actual.PreviousVersion)) } + // Check previous_contains: each listed version must appear in the recorded + // deploy-history ring, proving a later promotion carried the ring forward. + for _, want := range expect.PreviousContains { + if !containsString(actual.PreviousVersions, want) { + errs = append(errs, fmt.Errorf("state[%s].previous has no entry with version %q, got %v", + env, want, actual.PreviousVersions)) + } + } + // Check cleared divergence fields: each named field must read back empty. // Expresses the rejoin contract, which an empty expectation value alone // cannot assert (empty Ref/BaseSHA/Patches expectations are skipped above). diff --git a/e2e/harness/component_promote_test.go b/e2e/harness/component_promote_test.go index 02cbe4ac..78dc23b9 100644 --- a/e2e/harness/component_promote_test.go +++ b/e2e/harness/component_promote_test.go @@ -291,3 +291,49 @@ func TestParseComponentStates_Divergence(t *testing.T) { assert.Empty(t, components["web"]["prod"].Ref) assert.Empty(t, components["web"]["prod"].Patches) } + +// TestParseComponentStates_PreviousRing parses a component leaf carrying a +// deploy-history ring and asserts the ring versions are read back newest first, +// so previous_contains assertions can observe them. +func TestParseComponentStates_PreviousRing(t *testing.T) { + manifest := `ci: + state: + components: + api: + prod: + sha: apiprodsha2 + version: api-0.2.0 + previous: + - sha: apiprodsha1 + version: api-0.1.0 +` + states, err := parseComponentStates(manifest, "ci") + require.NoError(t, err) + prod := states["api"]["prod"] + require.Len(t, prod.Previous, 1) + assert.Equal(t, "api-0.1.0", prod.Previous[0].Version) +} + +// TestAssertState_PreviousContains verifies the previous_contains expectation: +// it passes when every listed version is in the recorded ring and fails with a +// named version when the ring is missing one (the empty-ring case is exactly +// what a finalize that rebuilds the env leaf from empty would produce). +func TestAssertState_PreviousContains(t *testing.T) { + ctx := NewExecutionContext() + key := componentStateKey("api", "prod") + ctx.RecordState(key, "apiprodsha2", "api-0.2.0") + ctx.RecordStatePreviousVersions(key, []string{"api-0.1.0"}) + + errs := AssertState(ctx, key, &StateExpect{PreviousContains: []string{"api-0.1.0"}}) + assert.Empty(t, errs, "a ring carrying the listed version must pass") + + errs = AssertState(ctx, key, &StateExpect{PreviousContains: []string{"api-0.0.1"}}) + require.Len(t, errs, 1, "a ring missing the listed version must fail") + assert.Contains(t, errs[0].Error(), "api-0.0.1") + + // An empty ring (the rebuilt-from-empty leaf) fails the expectation. + bare := componentStateKey("web", "prod") + ctx.RecordState(bare, "webprodsha", "web-0.1.0") + errs = AssertState(ctx, bare, &StateExpect{PreviousContains: []string{"web-0.0.1"}}) + require.Len(t, errs, 1, "an empty ring must fail previous_contains") +} diff --git a/e2e/harness/context.go b/e2e/harness/context.go index 1b31eedc..606bcd45 100644 --- a/e2e/harness/context.go +++ b/e2e/harness/context.go @@ -28,6 +28,11 @@ type EnvState struct { // PreviousVersion is the version held before the latest divergence update. // Harness-side only; not read back from the product manifest. PreviousVersion string + // PreviousVersions lists the versions recorded in the environment's + // deploy-history ring (state..previous, newest first), synced from the + // manifest. It lets a scenario assert a later promotion carried the recorded + // ring forward instead of rebuilding the env leaf from empty. + PreviousVersions []string } // DeployState tracks per-deploy state @@ -188,6 +193,19 @@ func (c *ExecutionContext) RecordStateDivergence(env, ref, baseSHA string, patch c.state[env].PreviousVersion = previousVersion } +// RecordStatePreviousVersions records the versions in an environment's +// deploy-history ring (state..previous, newest first) without disturbing +// the recorded SHA/Version. Used by manifest sync so previous_contains +// assertions can observe that a promotion carried the recorded ring forward. +func (c *ExecutionContext) RecordStatePreviousVersions(env string, versions []string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.state[env] == nil { + c.state[env] = &EnvState{Deploys: make(map[string]*DeployState)} + } + c.state[env].PreviousVersions = append([]string(nil), versions...) +} + // ClearState removes all env state. Used by sync routines that need to // rebuild ctx from an authoritative source (the manifest), so deletions in // that source (e.g. finalize wiping state[prerelease] on publish) are @@ -275,13 +293,14 @@ func (c *ExecutionContext) Clone() *ExecutionContext { clone.commitSeq = append([]string{}, c.commitSeq...) for k, v := range c.state { clone.state[k] = &EnvState{ - SHA: v.SHA, - Version: v.Version, - Deploys: make(map[string]*DeployState), - Ref: v.Ref, - BaseSHA: v.BaseSHA, - Patches: append([]string(nil), v.Patches...), - PreviousVersion: v.PreviousVersion, + SHA: v.SHA, + Version: v.Version, + Deploys: make(map[string]*DeployState), + Ref: v.Ref, + BaseSHA: v.BaseSHA, + Patches: append([]string(nil), v.Patches...), + PreviousVersion: v.PreviousVersion, + PreviousVersions: append([]string(nil), v.PreviousVersions...), } for dk, dv := range v.Deploys { clone.state[k].Deploys[dk] = &DeployState{SHA: dv.SHA} diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index cfa3f0a2..7af6f1d6 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -508,6 +508,11 @@ type StateExpect struct { // prior versions in a separate "previous" ring), so it is only populated by // setup staging or by an explicit divergence record. PreviousVersion string `yaml:"previous_version,omitempty"` + // PreviousContains lists versions that must each appear in the recorded + // deploy-history ring (state..previous, or the component leaf's ring + // when Component is set). It asserts a later promotion carried the recorded + // ring forward rather than rebuilding the env leaf from empty. + PreviousContains []string `yaml:"previous_contains,omitempty"` // Cleared names divergence fields that must now be empty on the recorded // state. Supported members: "ref", "base_sha", "patches". This expresses the // rejoin contract (divergence fields are cleared once an env rejoins trunk), diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index bbd1210a..0fb008f9 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -731,6 +731,11 @@ type componentEnvStateYAML struct { Ref string `yaml:"ref"` BaseSHA string `yaml:"base_sha"` Patches []string `yaml:"patches"` + // Previous is the deploy-history ring (previous, newest first) recorded in + // the component's env leaf; only the versions are read back for assertions. + Previous []struct { + Version string `yaml:"version"` + } `yaml:"previous"` } // parseComponentStates extracts ci.state.components.. rows from a @@ -1420,6 +1425,11 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { Ref string `yaml:"ref"` BaseSHA string `yaml:"base_sha"` Patches []string `yaml:"patches"` + // Previous is the deploy-history ring (state..previous, + // newest first); only the versions are read back for assertions. + Previous []struct { + Version string `yaml:"version"` + } `yaml:"previous"` } `yaml:"state"` // LatestRelease is the single-environment Release workflow's published // pointer (ci.latest_release). Single-env repos publish via the Release @@ -1457,6 +1467,17 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { r.ctx.RecordState(env, state.SHA, state.Version) r.t.Logf(" Synced state[%s] = %s @ %s", env, truncateSHA(state.SHA), state.Version) + // Record the deploy-history ring versions so previous_contains + // assertions can observe that a promotion carried the ring forward. + if len(state.Previous) > 0 { + ring := make([]string, 0, len(state.Previous)) + for _, p := range state.Previous { + ring = append(ring, p.Version) + } + r.ctx.RecordStatePreviousVersions(env, ring) + r.t.Logf(" Synced state[%s].previous versions = %v", env, ring) + } + // Record integration-branch divergence so divergence assertions can see // a hotfixed environment. PreviousVersion has no manifest key (the // product tracks prior versions separately), so it stays empty here. @@ -1501,6 +1522,17 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { 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) + // Record the component leaf's deploy-history ring versions so + // previous_contains assertions can observe that a component + // promotion carried the recorded ring forward. + if len(st.Previous) > 0 { + ring := make([]string, 0, len(st.Previous)) + for _, p := range st.Previous { + ring = append(ring, p.Version) + } + r.ctx.RecordStatePreviousVersions(key, ring) + r.t.Logf(" Synced state.components[%s][%s].previous versions = %v", comp, env, ring) + } // Record component-scoped divergence so a per-component hotfix or // rollback assertion (ref/base_sha/patches under the composite key) can // observe the diverged component while a sibling's subtree stays diff --git a/e2e/scenarios/51-component-promote-isolation.yaml b/e2e/scenarios/51-component-promote-isolation.yaml index e6777ad4..ded8b9ab 100644 --- a/e2e/scenarios/51-component-promote-isolation.yaml +++ b/e2e/scenarios/51-component-promote-isolation.yaml @@ -16,6 +16,11 @@ description: | under only its own subtree: neither promotion rebuilds, moves, or drops the other's state.components. subtree. + A second api cycle then promotes api-0.2.0 onto the already-recorded prod leaf + and asserts the deploy-history ring carried api-0.1.0 forward: a component + finalize that rebuilds the env leaf from empty instead of lifting the recorded + state would drop the ring (and every other recorded row) on this promotion. + config: trunk_branch: main environments: [dev, prod] @@ -118,3 +123,41 @@ steps: component: api env: prod unchanged: true + + # Second api cycle: advance api's source and cut its next dev prerelease. + - name: "Advance api sources for a second version line" + action: commit + commit: + message: "feat: advance api" + files: + services/api/feature.go: | + package main + + func feature() {} + + - name: "Orchestrate api to cut its second dev prerelease" + action: orchestrate + orchestrate: + component: api + + # Promote api's second version onto its already-recorded prod leaf. The + # finalize must lift the recorded component state before mutating it, so the + # outgoing api-0.1.0 state lands on the deploy-history ring instead of the + # whole leaf being rebuilt from empty. web's subtree again survives untouched. + - name: "Promote api's second version from dev to prod" + action: promote + promote: + mode: cascade + target: prod + component: api + expect: + state: + api-prod: + component: api + env: prod + version: "api-0.2.0" + previous_contains: ["api-0.1.0"] + web-prod: + component: web + env: prod + unchanged: true diff --git a/internal/promote/command_finalize.go b/internal/promote/command_finalize.go index 9d0e278f..201279d8 100644 --- a/internal/promote/command_finalize.go +++ b/internal/promote/command_finalize.go @@ -155,15 +155,11 @@ func runFinalize() error { return nil } - if err := fin.Run(); err != nil { + if err := persistAndCleanup(fin, commitPush); err != nil { return err } - // Commit and push if requested if commitPush { - if err := fin.CommitAndPush(); err != nil { - return fmt.Errorf("failed to commit and push: %w", err) - } fmt.Printf("State updated and committed for %s\n", targetEnv) } else { fmt.Printf("State updated for %s (not committed)\n", targetEnv) @@ -172,6 +168,27 @@ func runFinalize() error { return nil } +// persistAndCleanup runs the finalizer's state update and local manifest write, +// commits the state back to trunk when requested, and only then performs the +// divergence-end lifecycle cleanup. The ordering is load-bearing: cleanup +// deletes the rejoined env's integration branch, hotfix tags, and drafts, and +// the state write that authorizes those deletions must be durable first. If the +// trunk commit fails, no artifact is removed, so trunk never records an env as +// diverged while pointing at an already-deleted integration branch; a later +// successful rerun performs the cleanup. Without commitPush the local write is +// the caller's durable record and cleanup follows it, as before. +func persistAndCleanup(fin *Finalizer, commitPush bool) error { + if err := fin.Run(); err != nil { + return err + } + if commitPush { + if err := fin.CommitAndPush(); err != nil { + return fmt.Errorf("failed to commit and push: %w", err) + } + } + return fin.runLifecycleCleanup() +} + // readDeployResultsFromEnv reads DEPLOY_RESULT_ env vars and returns a // map of deploy name → conclusion for each deploy with a non-empty value. // Hyphens in deploy names are converted to underscores for the env var key, diff --git a/internal/promote/command_preflight.go b/internal/promote/command_preflight.go index f1a5b2e8..4c0d5cfa 100644 --- a/internal/promote/command_preflight.go +++ b/internal/promote/command_preflight.go @@ -64,7 +64,7 @@ func runPreflight(cmd *cobra.Command, args []string) error { // state.components..; overlay those rows into the working flat // state map so the source-env deployment check and version comparison resolve // the component's seed. A no-op for a single-component (empty) preflight. - if err := overlayComponentState(cicdFile, configPath, componentName); err != nil { + if err := overlayComponentState(cicdFile, configPath, config.DefaultManifestKey, componentName); err != nil { return err } diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index 727b92c7..6b93d292 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -94,6 +94,18 @@ func NewFinalizerWithKey(configPath, targetEnv, manifestKey string, opts ...Fina for _, opt := range opts { opt(f) } + + // A component-scoped finalize reads the component's recorded env state from + // state.components.., which the flat parse above cannot see. + // Overlay those rows into the working flat state map so updateState mutates + // the recorded leaf: the deploy-history ring is pushed rather than lost, the + // deploy rows this promotion did not touch survive, and a recorded divergence + // is seen so the rejoin lifecycle schedules its cleanup. The write path stays + // component-scoped, so an overlaid row never leaks back as flat state. A + // no-op for the single-component (empty component) path. + if err := overlayComponentState(f.cicdFile, configPath, manifestKey, f.component); err != nil { + return nil, err + } return f, nil } @@ -134,27 +146,31 @@ func (f *Finalizer) SetHeadSHA(sha string) { // // Note: This updates the in-memory state and writes to disk. // Call this only when you want to persist changes. +// +// Run deliberately does NOT perform the divergence-end lifecycle cleanup: the +// remote artifacts a rejoin removes (integration branch, hotfix tags, drafts) +// must outlive the state write that authorizes their removal, and when the +// caller commits state back to trunk that durable write happens after Run. +// Callers invoke runLifecycleCleanup once the state is durably recorded. func (f *Finalizer) Run() error { f.updateState() - if err := f.WriteConfig(); err != nil { - return err - } - return f.runLifecycleCleanup() + return f.WriteConfig() } // runLifecycleCleanup performs the divergence-end side effects for every env -// that rejoined trunk during this finalization. It runs only after the manifest -// is persisted, so the source of truth is updated before any branch, tag, or -// release object is removed. +// that rejoined trunk during this finalization. Callers invoke it only after +// the state recording the rejoin is durably persisted (the trunk commit when +// state is committed back, the local manifest write otherwise), so the source +// of truth is updated before any branch, tag, or release object is removed. // // Cleanup is best-effort and never aborts the finalize: the objects it removes // (integration branch, hotfix tags and release objects) are disposable // superseded artifacts, and every individual delete is idempotent, so a transient // failure on one env must not strand the others or wedge an already-persisted // state write. A failure on one env is logged loudly and cleanup continues with -// the rest; hard-fail is reserved for the state write, which Run performs before -// reaching here. When no env rejoined (the common, non-diverged case) this is a -// no-op and the injected cleaner is never called. +// the rest; hard-fail is reserved for the state write, which the caller +// completes before reaching here. When no env rejoined (the common, +// non-diverged case) this is a no-op and the injected cleaner is never called. func (f *Finalizer) runLifecycleCleanup() error { for _, ev := range f.pendingRejoins { if ev.rollbackOrigin { diff --git a/internal/promote/finalize_cleanup_order_test.go b/internal/promote/finalize_cleanup_order_test.go new file mode 100644 index 00000000..fc9a17e1 --- /dev/null +++ b/internal/promote/finalize_cleanup_order_test.go @@ -0,0 +1,94 @@ +package promote + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestFinalizerRun_DefersLifecycleCleanup proves Run persists state without +// firing the divergence-end cleanup: the remote artifacts a rejoin removes must +// outlive the local write, because the trunk state commit that authorizes their +// removal happens afterwards. Cleanup fires only when the caller invokes it +// after the state is durably recorded. +func TestFinalizerRun_DefersLifecycleCleanup(t *testing.T) { + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, fin.Run()) + require.Empty(t, cleaner.deletedBranches, + "Run must not delete remote artifacts before the trunk state commit") + require.Empty(t, cleaner.cleanedReleases, + "Run must not clean releases before the trunk state commit") + + require.NoError(t, fin.runLifecycleCleanup()) + require.Equal(t, []string{"env/test"}, cleaner.deletedBranches, + "cleanup still fires once invoked after the state is durable") + require.Len(t, cleaner.cleanedReleases, 1) +} + +// TestPersistAndCleanup_SkipsCleanupWhenCommitFails proves the finalize command +// flow never deletes the rejoined env's remote artifacts when the trunk state +// commit fails: trunk would still record the env as diverged, pointing at an +// integration branch the cleanup would have removed. +func TestPersistAndCleanup_SkipsCleanupWhenCommitFails(t *testing.T) { + // No GITHUB_REPOSITORY and a manifest outside any git repo: CommitAndPush + // fails deterministically on the API path before any cleanup could run. + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + t.Setenv("GITHUB_REPOSITORY", "") + + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.Error(t, persistAndCleanup(fin, true), + "a failed trunk commit must surface as an error") + require.Empty(t, cleaner.deletedBranches, + "no remote artifact may be deleted when the trunk state commit failed") + require.Empty(t, cleaner.cleanedReleases, + "no release may be cleaned when the trunk state commit failed") +} + +// TestPersistAndCleanup_NoCommit_StillCleansAfterLocalWrite covers the +// non-commit path: the local manifest write is the caller's durable record, so +// cleanup runs after it exactly as before. +func TestPersistAndCleanup_NoCommit_StillCleansAfterLocalWrite(t *testing.T) { + configPath := divergedManifest(t) + cleaner := &recordingCleaner{} + + fin, err := NewFinalizer(configPath, "test", WithLifecycleCleaner(cleaner)) + require.NoError(t, err) + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SourceEnv: "dev", + SHA: "trunkhead", + Version: "v1.4.0-rc.3", + }}, + }) + + require.NoError(t, persistAndCleanup(fin, false)) + require.Equal(t, []string{"env/test"}, cleaner.deletedBranches) + require.Len(t, cleaner.cleanedReleases, 1) +} diff --git a/internal/promote/finalize_component_state_test.go b/internal/promote/finalize_component_state_test.go new file mode 100644 index 00000000..0faf265e --- /dev/null +++ b/internal/promote/finalize_component_state_test.go @@ -0,0 +1,210 @@ +package promote + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// recordedComponentManifest seeds a component leaf the way a real promotion +// history leaves it: a deploy-history ring, per-deploy rows for two apps, and a +// sibling component. It is the fixture for proving a component-scoped finalize +// carries the recorded leaf forward instead of rebuilding it from empty. +const recordedComponentManifest = `ci: + config: + environments: [dev, prod] + state: + components: + billing: + prod: + sha: billsha + version: v2.0.0 + checkout: + prod: + sha: oldsha + version: v1.3.0 + committed_at: "2026-07-01T00:00:00Z" + committed_by: earlier + previous: + - sha: oldersha + version: v1.2.0 + deploys: + api: + sha: oldsha + version: v1.3.0 + web: + sha: oldsha + version: v1.3.0 +` + +// TestFinalizer_Component_CarriesForwardRecordedLeaf is the regression test for +// the component finalize state loss: a promotion of checkout's prod where only +// the web deploy ran must mutate the RECORDED leaf, so the deploy-history ring +// grows (rather than vanishing) and the api row it did not touch survives. +func TestFinalizer_Component_CarriesForwardRecordedLeaf(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(recordedComponentManifest), 0644)) + + fin, err := NewFinalizer(path, "prod", WithComponent("checkout"), WithClock(fixedClock())) + require.NoError(t, err) + fin.SetActor("promoter") + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{Environment: "prod", SourceEnv: "dev", SHA: "newsha", Version: "v1.4.0"}}, + }) + fin.SetDeployResult("web", "success") + fin.SetDeployResult("api", "skipped") + + require.NoError(t, fin.Run()) + + out, err := os.ReadFile(path) + require.NoError(t, err) + m := readManifestNode(t, out) + leaf := componentEnv(t, m, "checkout", "prod") + + // The env pointer advanced. + require.Equal(t, "newsha", leaf["sha"]) + require.Equal(t, "v1.4.0", leaf["version"]) + + // The outgoing state was pushed onto the recorded ring: newest first, with + // the previously recorded entry still behind it. + prev, ok := leaf["previous"].([]any) + require.True(t, ok, "previous ring must survive the component finalize, got: %#v", leaf["previous"]) + require.Len(t, prev, 2, "ring must carry the pushed snapshot plus the recorded entry") + head := prev[0].(map[string]any) + require.Equal(t, "oldsha", head["sha"], "outgoing state must be snapshotted onto the ring") + require.Equal(t, "v1.3.0", head["version"]) + tail := prev[1].(map[string]any) + require.Equal(t, "oldersha", tail["sha"], "the recorded ring entry must survive") + + // The deploy that ran was updated; the one that did not still carries its + // recorded row instead of being dropped by a whole-leaf rebuild. + deploys, ok := leaf["deploys"].(map[string]any) + require.True(t, ok, "deploys map must survive") + web := deploys["web"].(map[string]any) + require.Equal(t, "newsha", web["sha"]) + require.Equal(t, "v1.4.0", web["version"]) + api, ok := deploys["api"].(map[string]any) + require.True(t, ok, "the non-promoted api deploy row must survive a web-only promotion") + require.Equal(t, "oldsha", api["sha"]) + require.Equal(t, "v1.3.0", api["version"]) + + // The sibling component is untouched. + sib := componentEnv(t, m, "billing", "prod") + require.Equal(t, "billsha", sib["sha"]) +} + +// divergedRecordedComponentManifest seeds checkout's prod as diverged on its +// component-namespaced integration branch, the state a hotfix leaves behind. +const divergedRecordedComponentManifest = `ci: + config: + environments: [dev, prod] + components: + checkout: + path: checkout + tag_grammar: + prefix: checkout- + state: + components: + checkout: + prod: + sha: mergesha + version: checkout-1.3.0-rc.2.hotfix.1 + ref: env/checkout/prod + base_sha: basesha + patches: [patchX] +` + +// TestFinalizer_Component_RejoinFromRecordedDivergence proves a component +// promotion into a diverged env actually SEES the recorded divergence: the +// rejoin cleanup is scheduled against the component-namespaced integration +// branch and the prior hotfix version, and the divergence fields are cleared +// deliberately rather than silently dropped with no cleanup. +func TestFinalizer_Component_RejoinFromRecordedDivergence(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(divergedRecordedComponentManifest), 0644)) + + cleaner := &recordingCleaner{} + fin, err := NewFinalizer(path, "prod", + WithComponent("checkout"), + WithClock(fixedClock()), + WithLifecycleCleaner(cleaner), + ) + require.NoError(t, err) + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{Environment: "prod", SourceEnv: "dev", SHA: "trunkhead", Version: "checkout-1.4.0-rc.3"}}, + }) + + require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) + + // The rejoin was scheduled in the component's own namespace with the version + // the env held while diverged. + require.Equal(t, []string{"env/checkout/prod"}, cleaner.deletedBranches, + "the recorded divergence must schedule cleanup of the component's integration branch") + require.Len(t, cleaner.cleanedReleases, 1) + require.Equal(t, "checkout-1.3.0-rc.2.hotfix.1", cleaner.cleanedReleases[0].BaseVersion, + "the base cleaned is the version the component env held while diverged") + + // The persisted leaf rejoined trunk: pointer advanced, divergence cleared. + out, err := os.ReadFile(path) + require.NoError(t, err) + leaf := componentEnv(t, readManifestNode(t, out), "checkout", "prod") + require.Equal(t, "trunkhead", leaf["sha"]) + require.Equal(t, "checkout-1.4.0-rc.3", leaf["version"]) + _, hasRef := leaf["ref"] + require.False(t, hasRef, "ref must be cleared on rejoin") + _, hasBase := leaf["base_sha"] + require.False(t, hasBase, "base_sha must be cleared on rejoin") + _, hasPatches := leaf["patches"] + require.False(t, hasPatches, "patches must be cleared on rejoin") +} + +// TestFinalizeCommand_Component_PreservesRecordedState drives the full +// `promote finalize --component` command path against the recorded fixture, +// proving the command wires the component lift, not just the Finalizer type. +func TestFinalizeCommand_Component_PreservesRecordedState(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(recordedComponentManifest), 0644)) + + promotionResult := PromotionResult{ + Success: true, + FinalEnv: "prod", + Promotions: []EnvPromotion{ + {Environment: "prod", SourceEnv: "dev", SHA: "newsha", Version: "v1.4.0"}, + }, + } + promotionJSON, err := json.Marshal(promotionResult) + require.NoError(t, err) + + cmd := NewCommand() + cmd.SetArgs([]string{ + "finalize", + "--config", path, + "--component", "checkout", + "--promotion-result", string(promotionJSON), + }) + require.NoError(t, cmd.Execute()) + + out, err := os.ReadFile(path) + require.NoError(t, err) + m := readManifestNode(t, out) + leaf := componentEnv(t, m, "checkout", "prod") + + require.Equal(t, "newsha", leaf["sha"]) + prev, ok := leaf["previous"].([]any) + require.True(t, ok, "previous ring must survive the command-level component finalize") + require.Len(t, prev, 2) + deploys, ok := leaf["deploys"].(map[string]any) + require.True(t, ok, "recorded deploy rows must survive the command-level component finalize") + _, hasAPI := deploys["api"] + require.True(t, hasAPI, "the api row must survive a promotion that ran no deploys") + + sib := componentEnv(t, m, "billing", "prod") + require.Equal(t, "billsha", sib["sha"]) +} diff --git a/internal/promote/preflight_component_subset_test.go b/internal/promote/preflight_component_subset_test.go index 92f8427a..9450dd7b 100644 --- a/internal/promote/preflight_component_subset_test.go +++ b/internal/promote/preflight_component_subset_test.go @@ -15,7 +15,7 @@ func TestPreflighter_ComponentSubset_TreatsLastSubsetEnvAsFinal(t *testing.T) { path := writeSubsetManifest(t) cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey) require.NoError(t, err) - require.NoError(t, overlayComponentState(cicdFile, path, "api")) + require.NoError(t, overlayComponentState(cicdFile, path, config.DefaultManifestKey, "api")) require.NoError(t, applyComponentLadder(cicdFile, "api")) pf := NewPreflighter(PreflighterOptions{ @@ -41,7 +41,7 @@ func TestPreflighter_ComponentSubset_SiblingReachesProd(t *testing.T) { path := writeSubsetManifest(t) cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey) require.NoError(t, err) - require.NoError(t, overlayComponentState(cicdFile, path, "web")) + require.NoError(t, overlayComponentState(cicdFile, path, config.DefaultManifestKey, "web")) require.NoError(t, applyComponentLadder(cicdFile, "web")) pf := NewPreflighter(PreflighterOptions{ diff --git a/internal/promote/promote.go b/internal/promote/promote.go index 53b3e3c0..02c13d8c 100644 --- a/internal/promote/promote.go +++ b/internal/promote/promote.go @@ -102,7 +102,7 @@ func NewPromoter(opts PromoterOptions, options ...Option) (*Promoter, error) { // state map so every existing State[env] lookup transparently sees that // component's seed. The write path stays component-scoped, so the overlaid // rows never leak back into the manifest as flat state. - if err := overlayComponentState(cicdFile, opts.ConfigPath, opts.Component); err != nil { + if err := overlayComponentState(cicdFile, opts.ConfigPath, config.DefaultManifestKey, opts.Component); err != nil { return nil, err } @@ -134,13 +134,14 @@ func NewPromoter(opts PromoterOptions, options ...Option) (*Promoter, error) { // overlayComponentState overlays a component's recorded per-env state, read from // the manifest at configPath under state.components.., into the -// working flat state map. Every State[env] lookup in preflight and promotion then -// sees that component's seed without teaching each lookup about the components -// subtree. It is a no-op when component is empty, keeping the single-component -// path byte-identical. It is the read counterpart to the component-scoped state -// writes: those keep the persisted form component-scoped, so an overlaid row is -// never round-tripped back to the manifest as a flat state. node. -func overlayComponentState(cicdFile *config.CICDFile, configPath, component string) error { +// working flat state map. Every State[env] lookup in preflight, promotion, and +// finalization then sees that component's seed without teaching each lookup +// about the components subtree. It is a no-op when component is empty, keeping +// the single-component path byte-identical. It is the read counterpart to the +// component-scoped state writes: those keep the persisted form component-scoped, +// so an overlaid row is never round-tripped back to the manifest as a flat +// state. node. An empty manifestKey resolves to config.DefaultManifestKey. +func overlayComponentState(cicdFile *config.CICDFile, configPath, manifestKey, component string) error { if component == "" { return nil } @@ -148,7 +149,7 @@ func overlayComponentState(cicdFile *config.CICDFile, configPath, component stri if err != nil { return fmt.Errorf("failed to read config for component state: %w", err) } - compState, err := config.ReadComponentState(raw, config.DefaultManifestKey, component) + compState, err := config.ReadComponentState(raw, manifestKey, component) if err != nil { return fmt.Errorf("failed to read component state: %w", err) } diff --git a/internal/promote/rejoin_component_test.go b/internal/promote/rejoin_component_test.go index ae631f7a..b3b7455a 100644 --- a/internal/promote/rejoin_component_test.go +++ b/internal/promote/rejoin_component_test.go @@ -77,6 +77,7 @@ func TestRejoin_Component_DeletesComponentNamespacedBranch(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) require.Equal(t, []string{"env/api/test"}, cleaner.deletedBranches, "the rejoining component's branch must be deleted in its own namespace, not env/test") @@ -102,6 +103,7 @@ func TestRejoin_Component_NeverCrossDeletesSibling(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) require.Equal(t, []string{"env/api/test"}, cleaner.deletedBranches, "only the rejoining component's branch is deleted") @@ -129,6 +131,7 @@ func TestRejoin_Component_TagCollectionScopedToComponent(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) require.Len(t, cleaner.cleanedReleases, 1) req := cleaner.cleanedReleases[0] @@ -173,6 +176,7 @@ func TestRejoin_SingleComponent_ByteIdentical(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) require.Equal(t, []string{"env/test"}, cleaner.deletedBranches, "a single-component rejoin deletes env/ exactly as before") diff --git a/internal/promote/rejoin_integration_test.go b/internal/promote/rejoin_integration_test.go index b812b6e2..9a08eb31 100644 --- a/internal/promote/rejoin_integration_test.go +++ b/internal/promote/rejoin_integration_test.go @@ -146,6 +146,7 @@ func TestRejoin_Integration_FullLifecycle(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) // Manifest: test rejoined trunk, fields cleared; uat divergence preserved. cicd, err := config.ParseManifestFile("manifest.yaml", config.DefaultManifestKey) diff --git a/internal/promote/rejoin_test.go b/internal/promote/rejoin_test.go index 53d78b34..a7d11473 100644 --- a/internal/promote/rejoin_test.go +++ b/internal/promote/rejoin_test.go @@ -82,6 +82,7 @@ func TestRejoin_ClearsDivergenceFields(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) cicd, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) require.NoError(t, err) @@ -113,6 +114,7 @@ func TestRejoin_DeletesEnvBranch(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) require.Equal(t, []string{"env/test"}, cleaner.deletedBranches, "the rejoined env's integration branch must be deleted exactly once") @@ -135,6 +137,7 @@ func TestRejoin_CleansHotfixTagsAndDrafts(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) require.Len(t, cleaner.cleanedReleases, 1) req := cleaner.cleanedReleases[0] @@ -161,6 +164,7 @@ func TestRejoin_PreservesOtherEnvsDivergence(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) cicd, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) require.NoError(t, err) @@ -208,6 +212,7 @@ func TestNormalPromotion_NonDiverged_TouchesNoLifecycle(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) require.Empty(t, cleaner.deletedBranches, "non-diverged promotion must delete no branch") require.Empty(t, cleaner.cleanedReleases, "non-diverged promotion must clean no releases") diff --git a/internal/promote/rollback_rejoin_test.go b/internal/promote/rollback_rejoin_test.go index 8e0eb2bc..5c9c623c 100644 --- a/internal/promote/rollback_rejoin_test.go +++ b/internal/promote/rollback_rejoin_test.go @@ -52,6 +52,7 @@ func TestFinalize_RollbackRejoin_SkipsBranchDeletion(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) // A rollback-origin rejoin must not touch any integration branch or // hotfix release objects (none exist for a manual rollback). @@ -85,6 +86,7 @@ func TestFinalize_HotfixRejoin_DeletesBranch(t *testing.T) { }) require.NoError(t, fin.Run()) + require.NoError(t, fin.runLifecycleCleanup()) // A hotfix-origin rejoin still deletes the integration branch and cleans // its hotfix releases exactly as before.