From 9f37fb64eb04000c4b5bdb83dabde5f3016fedd0 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 14 Jun 2026 06:44:28 -0400 Subject: [PATCH] feat: populate per-environment deploy-history ring Signed-off-by: Joshua Temple --- internal/config/history.go | 35 ++++++++++ internal/config/history_test.go | 101 +++++++++++++++++++++++++++++ internal/config/types.go | 9 +-- internal/hotfix/finalize.go | 13 ++-- internal/hotfix/finalize_test.go | 53 +++++++++++++++ internal/promote/finalize.go | 12 +++- internal/promote/finalize_test.go | 81 +++++++++++++++++++++++ internal/rollback/rollback.go | 5 ++ internal/rollback/rollback_test.go | 65 +++++++++++++++++++ 9 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 internal/config/history.go create mode 100644 internal/config/history_test.go diff --git a/internal/config/history.go b/internal/config/history.go new file mode 100644 index 00000000..beb01c2f --- /dev/null +++ b/internal/config/history.go @@ -0,0 +1,35 @@ +package config + +// MaxPreviousSnapshots bounds the per-environment deploy-history ring +// (state..previous). Once the ring reaches this length the oldest snapshot +// is dropped as a newer one is prepended, so the ring records at most this many +// prior states, newest first. +const MaxPreviousSnapshots = 10 + +// PushPreviousSnapshot records the environment's current (outgoing) state into +// the deploy-history ring before the env pointer is advanced to newSHA. Callers +// invoke it just before overwriting SHA/Version/CommittedAt/CommittedBy, so the +// snapshot captures the state that is about to be replaced. +// +// It is a no-op when there is no prior state to record (s.SHA == "") or when the +// environment is not actually transitioning (s.SHA == newSHA). Otherwise it +// prepends a snapshot of {SHA, Version, CommittedAt, CommittedBy} so the ring is +// ordered newest first, then caps the ring at MaxPreviousSnapshots by dropping +// the oldest entries. It is safe to call when s.Previous is nil. +func (s *EnvState) PushPreviousSnapshot(newSHA string) { + if s.SHA == "" || s.SHA == newSHA { + return + } + + snapshot := EnvStateSnapshot{ + SHA: s.SHA, + Version: s.Version, + CommittedAt: s.CommittedAt, + CommittedBy: s.CommittedBy, + } + + s.Previous = append([]EnvStateSnapshot{snapshot}, s.Previous...) + if len(s.Previous) > MaxPreviousSnapshots { + s.Previous = s.Previous[:MaxPreviousSnapshots] + } +} diff --git a/internal/config/history_test.go b/internal/config/history_test.go new file mode 100644 index 00000000..99cee179 --- /dev/null +++ b/internal/config/history_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestPushPreviousSnapshot_PrependsPriorOnTransition(t *testing.T) { + s := &EnvState{ + SHA: "old123", + Version: "v1.0.0", + CommittedAt: "2026-01-01T10:00:00Z", + CommittedBy: "alice", + } + + s.PushPreviousSnapshot("new456") + + require.Len(t, s.Previous, 1) + assert.Equal(t, EnvStateSnapshot{ + SHA: "old123", + Version: "v1.0.0", + CommittedAt: "2026-01-01T10:00:00Z", + CommittedBy: "alice", + }, s.Previous[0]) + + // A second transition prepends the next snapshot, newest first. + s.SHA = "new456" + s.Version = "v1.1.0" + s.CommittedAt = "2026-01-02T10:00:00Z" + s.CommittedBy = "bob" + s.PushPreviousSnapshot("third789") + + require.Len(t, s.Previous, 2) + assert.Equal(t, "new456", s.Previous[0].SHA) + assert.Equal(t, "old123", s.Previous[1].SHA) +} + +func TestPushPreviousSnapshot_BoundedToMax(t *testing.T) { + s := &EnvState{} + + // Drive more transitions than the cap and confirm the ring stays bounded, + // newest first, dropping the oldest entries. + total := MaxPreviousSnapshots + 5 + for i := 0; i < total; i++ { + s.SHA = "sha" + strconv.Itoa(i) + s.Version = "v0.0." + strconv.Itoa(i) + s.PushPreviousSnapshot("sha" + strconv.Itoa(i+1)) + } + + require.Len(t, s.Previous, MaxPreviousSnapshots) + // Newest first: the most recent outgoing SHA is "sha{total-1}". + assert.Equal(t, "sha"+strconv.Itoa(total-1), s.Previous[0].SHA) + assert.Equal(t, "sha"+strconv.Itoa(total-MaxPreviousSnapshots), s.Previous[MaxPreviousSnapshots-1].SHA) +} + +func TestPushPreviousSnapshot_SkipsNoOpSameSHA(t *testing.T) { + s := &EnvState{SHA: "same111", Version: "v1.0.0"} + + s.PushPreviousSnapshot("same111") + + assert.Empty(t, s.Previous) +} + +func TestPushPreviousSnapshot_SkipsWhenNoPriorSHA(t *testing.T) { + s := &EnvState{} // no prior SHA + + s.PushPreviousSnapshot("new456") + + assert.Empty(t, s.Previous) +} + +func TestPreviousRing_YAMLRoundTrip(t *testing.T) { + original := &EnvState{ + SHA: "head000", + Version: "v2.0.0", + CommittedAt: "2026-02-01T10:00:00Z", + CommittedBy: "carol", + Previous: []EnvStateSnapshot{ + {SHA: "prev111", Version: "v1.1.0", CommittedAt: "2026-01-15T10:00:00Z", CommittedBy: "bob"}, + {SHA: "prev000", Version: "v1.0.0", CommittedAt: "2026-01-01T10:00:00Z", CommittedBy: "alice"}, + }, + } + + data, err := yaml.Marshal(original) + require.NoError(t, err) + + var got EnvState + require.NoError(t, yaml.Unmarshal(data, &got)) + + assert.Equal(t, original.Previous, got.Previous) + + // omitempty: an env with no ring does not emit the key. + empty := &EnvState{SHA: "x"} + emptyData, err := yaml.Marshal(empty) + require.NoError(t, err) + assert.NotContains(t, string(emptyData), "previous:") +} diff --git a/internal/config/types.go b/internal/config/types.go index fe06479b..5b48ed91 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -38,8 +38,9 @@ type EnvState struct { BaseSHA string `yaml:"base_sha,omitempty" json:"base_sha,omitempty"` // Patches lists the patch commit SHAs applied on top of BaseSHA. Patches []string `yaml:"patches,omitempty" json:"patches,omitempty"` - // Previous is the reserved "roll back to N-1" ring (#23). Reserved-shape, - // optional: populated only if deterministic history-walking is wired later. + // Previous is the per-environment deploy-history ring (#23): snapshots of + // prior env states, newest first, bounded to MaxPreviousSnapshots. Populated + // on every state transition via PushPreviousSnapshot. Previous []EnvStateSnapshot `yaml:"previous,omitempty" json:"previous,omitempty"` } @@ -52,8 +53,8 @@ func (s *EnvState) IsDiverged() bool { return s.Ref != "" || len(s.Patches) > 0 } -// EnvStateSnapshot is a single prior env-state entry in the reserved rollback -// ring (state..previous). Reserved-shape only. +// EnvStateSnapshot is a single prior env-state entry in the deploy-history +// ring (state..previous). type EnvStateSnapshot struct { SHA string `yaml:"sha,omitempty" json:"sha,omitempty"` Version string `yaml:"version,omitempty" json:"version,omitempty"` diff --git a/internal/hotfix/finalize.go b/internal/hotfix/finalize.go index 17c4352c..64742494 100644 --- a/internal/hotfix/finalize.go +++ b/internal/hotfix/finalize.go @@ -334,14 +334,11 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error return nil } - // Snapshot the prior state into the Previous ring (newest first). - snapshot := config.EnvStateSnapshot{ - SHA: prior.SHA, - Version: prior.Version, - CommittedAt: prior.CommittedAt, - CommittedBy: prior.CommittedBy, - } - prior.Previous = append([]config.EnvStateSnapshot{snapshot}, prior.Previous...) + // Snapshot the prior state into the deploy-history ring (newest first, + // bounded). The idempotency gate above already returned when the state + // records mergeSHA, so this records a genuine transition; the gate inside + // PushPreviousSnapshot is belt-and-suspenders. + prior.PushPreviousSnapshot(mergeSHA) // Carry BaseSHA forward when already diverged; otherwise anchor it now. if prior.BaseSHA == "" { diff --git a/internal/hotfix/finalize_test.go b/internal/hotfix/finalize_test.go index f3e8d26a..e39cef52 100644 --- a/internal/hotfix/finalize_test.go +++ b/internal/hotfix/finalize_test.go @@ -3,6 +3,7 @@ package hotfix import ( "os" "path/filepath" + "strconv" "strings" "testing" @@ -229,6 +230,58 @@ func TestFinalize_PreviousRingSnapshot(t *testing.T) { } } +func TestFinalize_PreviousRingBounded(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix") + runGit(t, "branch", "env/test", base) + runGit(t, "checkout", "env/test") + merge := commitFile(t, "c.txt", "fixed", "cp") + runGit(t, "checkout", "main") + + // Seed test with a deploy-history ring already at the cap, then finalize a + // genuine transition: the new snapshot prepends and the oldest is dropped, so + // the ring stays bounded at MaxPreviousSnapshots. + var b strings.Builder + b.WriteString("ci:\n config:\n environments:\n") + for _, e := range []string{"dev", "test", "prod"} { + b.WriteString(" - " + e + "\n") + } + b.WriteString(" state:\n") + b.WriteString(" dev:\n sha: " + fix + "\n version: v1.4.0-rc.2\n") + b.WriteString(" prod:\n sha: " + base + "\n version: v1.4.0-rc.2\n") + b.WriteString(" test:\n sha: " + base + "\n version: v1.4.0-rc.2\n") + b.WriteString(" previous:\n") + for i := 0; i < config.MaxPreviousSnapshots; i++ { + b.WriteString(" - sha: seed" + strconv.Itoa(i) + "\n version: v0.0." + strconv.Itoa(i) + "\n") + } + path := filepath.Join(".", "manifest.yaml") + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + f := newFinalizer(t, path, + WithReleaseManager(&stubReleaseManager{}), + WithTagLister(stubTagLister{}), + WithStatePusher(&recordingPusher{}), + ) + if err := f.Finalize("test", merge, fix, base); err != nil { + t.Fatalf("Finalize: %v", err) + } + + st := loadState(t, path, "test") + if len(st.Previous) != config.MaxPreviousSnapshots { + t.Fatalf("previous ring len = %d, want %d (bounded)", len(st.Previous), config.MaxPreviousSnapshots) + } + // Newest snapshot is the outgoing pre-hotfix state; oldest seed was dropped. + if st.Previous[0].SHA != base { + t.Errorf("newest snapshot sha = %q, want prior sha %q", st.Previous[0].SHA, base) + } + if st.Previous[len(st.Previous)-1].SHA == "seed0" { + t.Errorf("oldest seed snapshot was not evicted: %+v", st.Previous) + } +} + func TestFinalize_StacksSecondHotfix(t *testing.T) { newScratchRepo(t) base := commitFile(t, "a.txt", "one", "first") diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index e7553cbe..04daecf7 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -172,11 +172,17 @@ func (f *Finalizer) updateState() { // When an auto-committing callback ran, overrideSHA holds the // post-callback HEAD; use it so the recorded state points at the // commit that was actually built/deployed rather than the triggering SHA. + newSHA := promo.SHA if f.overrideSHA != "" { - state.SHA = f.overrideSHA - } else { - state.SHA = promo.SHA + newSHA = f.overrideSHA } + + // Record the outgoing state in the deploy-history ring before the + // env pointer advances. No-op when there is no prior SHA or the env + // is not actually transitioning. + state.PushPreviousSnapshot(newSHA) + + state.SHA = newSHA state.Version = promo.Version state.CommittedAt = timestamp state.CommittedBy = f.actor diff --git a/internal/promote/finalize_test.go b/internal/promote/finalize_test.go index d722b08c..f56eb390 100644 --- a/internal/promote/finalize_test.go +++ b/internal/promote/finalize_test.go @@ -752,3 +752,84 @@ func TestCommitAndPushGit_DetachedHeadPushesToTrunk(t *testing.T) { require.NoError(t, err, "reading remote main log: %s", out) require.Contains(t, string(out), "update state after promotion to test") } + +func TestUpdateState_PushesPriorSnapshotOnTransition(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + + // test already holds a prior state; promoting a new SHA into it records the + // outgoing state in the deploy-history ring. + initialConfig := `ci: + config: + environments: [dev, test, uat, prod] + state: + test: + sha: oldsha111 + version: v1.0.0 + committed_at: "2026-01-01T10:00:00Z" + committed_by: alice +` + require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644)) + + fin, err := NewFinalizer(configPath, "test") + require.NoError(t, err) + fin.SetActor("bob") + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SHA: "newsha222", + Version: "v1.1.0", + }}, + }) + + require.NoError(t, fin.Run()) + + cicdFile, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) + require.NoError(t, err) + + testState := cicdFile.State["test"] + require.NotNil(t, testState) + require.Equal(t, "newsha222", testState.SHA) + require.Len(t, testState.Previous, 1) + require.Equal(t, "oldsha111", testState.Previous[0].SHA) + require.Equal(t, "v1.0.0", testState.Previous[0].Version) + require.Equal(t, "alice", testState.Previous[0].CommittedBy) +} + +func TestUpdateState_NoSnapshotOnSameSHA(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + + // Promoting the same SHA the env already records is not a transition, so no + // snapshot is pushed. + initialConfig := `ci: + config: + environments: [dev, test, uat, prod] + state: + test: + sha: samesha111 + version: v1.0.0 + committed_at: "2026-01-01T10:00:00Z" + committed_by: alice +` + require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644)) + + fin, err := NewFinalizer(configPath, "test") + require.NoError(t, err) + fin.SetPromotionResult(&PromotionResult{ + Promotions: []EnvPromotion{{ + Environment: "test", + SHA: "samesha111", + Version: "v1.0.0", + }}, + }) + + require.NoError(t, fin.Run()) + + cicdFile, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) + require.NoError(t, err) + + testState := cicdFile.State["test"] + require.NotNil(t, testState) + require.Empty(t, testState.Previous) +} diff --git a/internal/rollback/rollback.go b/internal/rollback/rollback.go index 03fa3118..2d565aa4 100644 --- a/internal/rollback/rollback.go +++ b/internal/rollback/rollback.go @@ -295,6 +295,11 @@ func (r *Rollbacker) Apply(plan *Plan) error { // Environment-scoped rollback: re-apply the env pointer and mirror the // SHA onto every recorded deployable so change-detection compares // against the rolled-back base. + // + // Record the outgoing state in the deploy-history ring before the env + // pointer advances. No-op when there is no prior SHA or the rollback + // target equals the current SHA. + env.PushPreviousSnapshot(plan.Target.SHA) env.SHA = plan.Target.SHA env.Version = plan.Target.Version env.CommittedAt = timestamp diff --git a/internal/rollback/rollback_test.go b/internal/rollback/rollback_test.go index 25658577..7d0d6828 100644 --- a/internal/rollback/rollback_test.go +++ b/internal/rollback/rollback_test.go @@ -329,3 +329,68 @@ func TestShaMatches(t *testing.T) { } } } + +func TestApply_PushesPriorSnapshotOnEnvRollback(t *testing.T) { + dir := t.TempDir() + // Live prod is at v2.0.0; the rollback target lives only in history. + path := writeManifest(t, dir, "newprodsha1234", "v2.0.0") + hist := fakeHistory{states: map[string][]*config.EnvState{ + "prod": { + {SHA: "oldprodsha5678", Version: "v1.8.0", + Deploys: map[string]*config.DeployState{ + "services": {SHA: "oldprodsha5678", Version: "v1.8.0"}, + }}, + }, + }} + rb := newRollbacker(t, path, hist) + + plan, err := rb.Plan("prod", "v1.8.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if err := rb.Apply(plan); err != nil { + t.Fatalf("Apply: %v", err) + } + + file, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + prod := file.State["prod"] + if len(prod.Previous) != 1 { + t.Fatalf("previous ring len = %d, want 1: %+v", len(prod.Previous), prod.Previous) + } + // The snapshot captures the outgoing (pre-rollback) state, newest first. + if prod.Previous[0].SHA != "newprodsha1234" { + t.Errorf("snapshot sha = %q, want newprodsha1234", prod.Previous[0].SHA) + } + if prod.Previous[0].Version != "v2.0.0" { + t.Errorf("snapshot version = %q, want v2.0.0", prod.Previous[0].Version) + } +} + +func TestApply_NoSnapshotWhenSameSHA(t *testing.T) { + dir := t.TempDir() + // Target matches the current state, so Apply is a no-op and records nothing. + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + + plan, err := rb.Plan("prod", "v1.9.0", "") + if err != nil { + t.Fatalf("Plan: %v", err) + } + if !plan.NoOp { + t.Fatal("expected NoOp plan") + } + if err := rb.Apply(plan); err != nil { + t.Fatalf("Apply: %v", err) + } + + file, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + t.Fatalf("re-parse: %v", err) + } + if prev := file.State["prod"].Previous; len(prev) != 0 { + t.Errorf("previous ring = %+v, want empty", prev) + } +}