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
35 changes: 35 additions & 0 deletions internal/config/history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package config

// MaxPreviousSnapshots bounds the per-environment deploy-history ring
// (state.<env>.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]
}
}
101 changes: 101 additions & 0 deletions internal/config/history_test.go
Original file line number Diff line number Diff line change
@@ -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:")
}
9 changes: 5 additions & 4 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand All @@ -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.<env>.previous). Reserved-shape only.
// EnvStateSnapshot is a single prior env-state entry in the deploy-history
// ring (state.<env>.previous).
type EnvStateSnapshot struct {
SHA string `yaml:"sha,omitempty" json:"sha,omitempty"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
Expand Down
13 changes: 5 additions & 8 deletions internal/hotfix/finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
53 changes: 53 additions & 0 deletions internal/hotfix/finalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hotfix
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -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")
Expand Down
12 changes: 9 additions & 3 deletions internal/promote/finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions internal/promote/finalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
5 changes: 5 additions & 0 deletions internal/rollback/rollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading