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
9 changes: 9 additions & 0 deletions e2e/harness/assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 46 additions & 0 deletions e2e/harness/component_promote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
33 changes: 26 additions & 7 deletions e2e/harness/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<env>.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
Expand Down Expand Up @@ -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.<env>.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
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<env>.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),
Expand Down
32 changes: 32 additions & 0 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.<env> rows from a
Expand Down Expand Up @@ -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.<env>.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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions e2e/scenarios/51-component-promote-isolation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ description: |
under only its own subtree: neither promotion rebuilds, moves, or drops the
other's state.components.<name> 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]
Expand Down Expand Up @@ -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
27 changes: 22 additions & 5 deletions internal/promote/command_finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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_<NAME> 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,
Expand Down
2 changes: 1 addition & 1 deletion internal/promote/command_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func runPreflight(cmd *cobra.Command, args []string) error {
// state.components.<component>.<env>; 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
}

Expand Down
36 changes: 26 additions & 10 deletions internal/promote/finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<component>.<env>, 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
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading