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

import (
"strings"
"testing"
)

// TestWriteScopedState_SingleComponentFormByteIdentical drives the
// single-component (Component == "") branch of WriteScopedState directly, so the
// call lands in applySingleComponentWrites. Every production single-component
// writer reaches that branch only through the WriteManifestState wrapper, so the
// wrapper goldens exercise a related but distinct entry point; a regression in the
// direct WriteScopedState single-component path ships green against them.
//
// The test pins two invariants: the emitted state keeps the flat state.<env>
// shape with no components.<name> nesting (the pre-multi-component leaf), and the
// bytes are identical both to the WriteManifestState wrapper for the equivalent
// map and to the historical whole-node-replace oracle.
func TestWriteScopedState_SingleComponentFormByteIdentical(t *testing.T) {
latest := &LatestReleaseState{Version: "v1.2.0", SHA: "rcsha", ReleasedOn: "2026-01-01T00:00:00Z"}

// Drive WriteScopedState directly with single-component (Component == "")
// writes. publishManifest still carries a prerelease node; the single-component
// rebuild drops it by omission, matching the wrapper.
got, err := WriteScopedState([]byte(publishManifest), "ci",
StateWrite{Env: "dev", State: &EnvState{SHA: "devsha", Version: "v1.2.0"}},
StateWrite{Env: "staging", State: &EnvState{SHA: "stagingsha", Version: "v1.2.0"}},
StateWrite{Env: "prod", State: &EnvState{SHA: "prodsha", Version: "v1.1.0"}},
StateWrite{Env: "release", State: &EnvState{SHA: "rcsha", Version: "v1.2.0"}},
StateWrite{Latest: latest},
)
if err != nil {
t.Fatalf("WriteScopedState single-component: %v", err)
}

// The single-component write must keep the flat state.<env> shape: no
// components.<name> nesting ever appears on this path.
if strings.Contains(string(got), "components:") {
t.Fatalf("single-component write leaked component nesting:\n%s", got)
}
for _, env := range []string{"dev:", "staging:", "prod:", "release:"} {
if !strings.Contains(string(got), env) {
t.Fatalf("single-component write dropped the flat %s leaf:\n%s", env, got)
}
}
if strings.Contains(string(got), "prerelease") {
t.Fatalf("single-component rebuild left a stale prerelease node:\n%s", got)
}

// Byte-identity against the WriteManifestState wrapper for the equivalent map:
// the direct single-component write and the wrapper must converge on the same
// bytes.
final := map[string]*EnvState{
"dev": {SHA: "devsha", Version: "v1.2.0"},
"staging": {SHA: "stagingsha", Version: "v1.2.0"},
"prod": {SHA: "prodsha", Version: "v1.1.0"},
"release": {SHA: "rcsha", Version: "v1.2.0"},
}
want, err := WriteManifestState([]byte(publishManifest), "ci", final, latest)
if err != nil {
t.Fatalf("WriteManifestState: %v", err)
}
if string(got) != string(want) {
t.Fatalf("single-component WriteScopedState not byte-identical to WriteManifestState\n--- got ---\n%s\n--- want ---\n%s", got, want)
}

// And identical to the historical whole-node-replace oracle, pinning the flat
// shape to the pre-multi-component byte output.
oracle := referenceWholeNodeReplace(t, []byte(publishManifest), "ci", final, latest)
if string(got) != string(oracle) {
t.Fatalf("single-component WriteScopedState not byte-identical to whole-node-replace oracle\n--- got ---\n%s\n--- want ---\n%s", got, oracle)
}
}
48 changes: 48 additions & 0 deletions internal/generate/state_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,54 @@ func TestStateWriteRetryCeilingAndConvergenceMarker(t *testing.T) {
assert.NotContains(t, content, "RANDOM % 5 + 2", "the fixed RANDOM sleep must be replaced by exponential jittered backoff")
}

// TestStateWriteEmitsSkipCIMarker asserts the generated state-write step stamps
// the [skip ci] marker on the commit message in BOTH the git-push (act/gitea)
// path and the Contents-API (real GitHub) path. The marker is load-bearing: it
// suppresses the tag-push CI trigger on the state commit so the candidate release
// is dispatched explicitly rather than racing a native tag-push trigger. A
// regression dropping it would let a state commit re-trigger the pipeline. The
// marker was previously asserted nowhere on the generator emission.
func TestStateWriteEmitsSkipCIMarker(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644))

cfg := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev"},
Builds: []config.BuildConfig{
{Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}},
},
}
content, err := NewGenerator(cfg, tmpDir).Generate()
require.NoError(t, err)

// git-push path (act/gitea): the shell commit carries the marker.
assert.Contains(t, content, `git commit -m "chore: update state for $ENVIRONMENT [skip ci]"`,
"git-push state commit must carry [skip ci] to suppress the tag-push trigger")
// Contents-API path (real GitHub): the API message carries the marker.
assert.Contains(t, content, `message=chore: update state for $ENVIRONMENT [skip ci]`,
"Contents-API state commit must carry [skip ci] to suppress the tag-push trigger")
}

// TestHotfixCherryPickCommitOmitsSkipCIMarker is the negative half of the skip-ci
// contract: a hotfix cherry-pick conflict commit is a real code commit that must
// flow through CI, so it must NOT carry the [skip ci] state-suppression marker.
// This guards against a blanket marker stamp leaking onto a non-state commit.
func TestHotfixCherryPickCommitOmitsSkipCIMarker(t *testing.T) {
content, err := NewHotfixGenerator(threeEnvHotfixConfig(), "").Generate()
require.NoError(t, err)
require.Contains(t, content, "cherry-pick",
"hotfix workflow should emit the cherry-pick recovery commit")

for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, "cherry-pick") && strings.Contains(line, "git commit -m") {
assert.NotContains(t, line, "[skip ci]",
"hotfix cherry-pick conflict commit must not carry [skip ci]; it is a real commit that must trigger CI")
}
}
}

// TestStateWriteNoEmDash guards the hard project rule that generated output
// contains no em dashes.
func TestStateWriteNoEmDash(t *testing.T) {
Expand Down
81 changes: 81 additions & 0 deletions internal/git/backoff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package git

import (
"testing"
"time"
)

// TestBackoffForAttempt_GrowthCapAndJitter drives the unexported backoffForAttempt
// directly. The push-retry tests inject a no-op sleep that counts calls but
// discards the duration, so only the retry ceiling is asserted; the timing curve
// itself is otherwise unverified. This pins the three documented properties of the
// pure function, with no real sleeping: the deterministic floor doubles per
// attempt, is capped at maxPushBackoff, and the jitter added on top stays within
// [0, base].
func TestBackoffForAttempt_GrowthCapAndJitter(t *testing.T) {
const base = defaultPushBackoff

// floorForAttempt is the deterministic (pre-jitter) component: base doubled per
// attempt, capped at maxPushBackoff. It mirrors the growth loop in
// backoffForAttempt so the jitter can be isolated as got-floor.
floorForAttempt := func(attempt int) time.Duration {
d := base
for i := 0; i < attempt && d < maxPushBackoff; i++ {
d *= 2
}
if d > maxPushBackoff {
d = maxPushBackoff
}
return d
}

sawJitter := false
for attempt := 0; attempt <= 8; attempt++ {
floor := floorForAttempt(attempt)

// Doubling: each floor is twice the previous until the cap clamps it.
if attempt > 0 {
want := floorForAttempt(attempt-1) * 2
if want > maxPushBackoff {
want = maxPushBackoff
}
if floor != want {
t.Fatalf("attempt %d floor = %v, want %v (doubling/cap)", attempt, floor, want)
}
}

// Every jittered sample must land in [floor, floor+base]; at least one
// sample across the sweep must exceed the floor, proving jitter is applied.
for i := 0; i < 2000; i++ {
got := backoffForAttempt(base, attempt)
if got < floor || got > floor+base {
t.Fatalf("attempt %d backoff = %v, want within [%v, %v]", attempt, got, floor, floor+base)
}
if got > floor {
sawJitter = true
}
}
}

// The cap holds: a very high attempt never grows the floor past maxPushBackoff.
if got := floorForAttempt(64); got != maxPushBackoff {
t.Fatalf("floor at attempt 64 = %v, want cap %v", got, maxPushBackoff)
}
// The live function honours the cap too: at a high attempt the result stays in
// [maxPushBackoff, maxPushBackoff+base].
for i := 0; i < 2000; i++ {
got := backoffForAttempt(base, 64)
if got < maxPushBackoff || got > maxPushBackoff+base {
t.Fatalf("capped backoff = %v, want within [%v, %v]", got, maxPushBackoff, maxPushBackoff+base)
}
}
if !sawJitter {
t.Fatal("jitter never observed across 8 attempts; de-sync jitter is not applied")
}

// A zero/negative base falls back to defaultPushBackoff rather than collapsing
// to a fixed no-backoff.
if got := backoffForAttempt(0, 0); got < defaultPushBackoff || got > 2*defaultPushBackoff {
t.Fatalf("zero-base backoff = %v, want within [%v, %v]", got, defaultPushBackoff, 2*defaultPushBackoff)
}
}
62 changes: 62 additions & 0 deletions internal/reset/reset_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,73 @@ import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stablekernel/cascade/internal/config"
)

// TestCommitAndPush_StateCommitCarriesSkipCIMarker drives the runtime reset commit
// path and asserts the landed commit subject carries the [skip ci] marker. The
// marker suppresses CI on the state commit; a regression dropping it would let
// every reset re-trigger the pipeline. The runtime emission was previously
// asserted nowhere.
func TestCommitAndPush_StateCommitCarriesSkipCIMarker(t *testing.T) {
const relPath = "manifest.yaml"

origin := t.TempDir()
if out, err := exec.Command("git", "init", "--bare", "-b", "main", origin).CombinedOutput(); err != nil {
t.Fatalf("git init --bare: %v\n%s", err, out)
}

const seed = `ci:
config:
trunk_branch: main
environments:
- dev
state:
dev:
sha: seedsha
version: v1.0.0
`
bootstrap := t.TempDir()
gitInDir(t, "", "clone", origin, bootstrap)
configureGitIdentity(t, bootstrap)
require.NoError(t, os.WriteFile(filepath.Join(bootstrap, relPath), []byte(seed), 0o600))
gitInDir(t, bootstrap, "add", relPath)
gitInDir(t, bootstrap, "commit", "-m", "seed manifest")
gitInDir(t, bootstrap, "push", "origin", "HEAD:main")

resetRepo := t.TempDir()
gitInDir(t, "", "clone", origin, resetRepo)
configureGitIdentity(t, resetRepo)

configPath := filepath.Join(resetRepo, relPath)
cicdFile, err := config.ParseManifestFile(configPath, config.DefaultManifestKey)
require.NoError(t, err)

r := &Resetter{
opts: Options{RepoPath: resetRepo, ResetState: true, Push: true},
repoPath: resetRepo,
repoOwner: "test",
repoName: "repo",
configPath: configPath,
manifestKey: config.DefaultManifestKey,
cicdFile: cicdFile,
}

// The reset clears state on disk, producing a diff for commitAndPush to land.
require.NoError(t, r.resetState())
require.NoError(t, r.commitAndPush())

out, err := exec.Command("git", "-C", origin, "log", "-1", "--format=%B", "main").Output()
require.NoError(t, err)
assert.Contains(t, string(out), "[skip ci]",
"runtime reset state commit must carry [skip ci] to suppress CI on the state write")
assert.Equal(t, resetCommitMessage, strings.TrimSpace(string(out)),
"reset commit subject must be the skip-ci state message")
}

// gitInDir runs a git command scoped to dir (matching how the Resetter scopes
// every git invocation to r.repoPath) and fails the test on error.
func gitInDir(t *testing.T, dir string, args ...string) {
Expand Down
81 changes: 81 additions & 0 deletions internal/statewrite/backoff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package statewrite

import (
"testing"
"time"
)

// TestBackoffForAttempt_GrowthCapAndJitter drives the unexported backoffForAttempt
// directly. The commit-retry tests inject a no-op sleep that counts calls but
// discards the duration, so only the retry ceiling is asserted; the timing curve
// itself is otherwise unverified. This pins the three documented properties of the
// pure function, with no real sleeping: the deterministic floor doubles per
// attempt, is capped at maxRetryBackoff, and the jitter added on top stays within
// [0, base].
func TestBackoffForAttempt_GrowthCapAndJitter(t *testing.T) {
const base = retryBaseBackoff

// floorForAttempt is the deterministic (pre-jitter) component: base doubled per
// attempt, capped at maxRetryBackoff. It mirrors the growth loop in
// backoffForAttempt so the jitter can be isolated as got-floor.
floorForAttempt := func(attempt int) time.Duration {
d := base
for i := 0; i < attempt && d < maxRetryBackoff; i++ {
d *= 2
}
if d > maxRetryBackoff {
d = maxRetryBackoff
}
return d
}

sawJitter := false
for attempt := 0; attempt <= 8; attempt++ {
floor := floorForAttempt(attempt)

// Doubling: each floor is twice the previous until the cap clamps it.
if attempt > 0 {
want := floorForAttempt(attempt-1) * 2
if want > maxRetryBackoff {
want = maxRetryBackoff
}
if floor != want {
t.Fatalf("attempt %d floor = %v, want %v (doubling/cap)", attempt, floor, want)
}
}

// Every jittered sample must land in [floor, floor+base]; at least one
// sample across the sweep must exceed the floor, proving jitter is applied.
for i := 0; i < 2000; i++ {
got := backoffForAttempt(base, attempt)
if got < floor || got > floor+base {
t.Fatalf("attempt %d backoff = %v, want within [%v, %v]", attempt, got, floor, floor+base)
}
if got > floor {
sawJitter = true
}
}
}

// The cap holds: a very high attempt never grows the floor past maxRetryBackoff.
if got := floorForAttempt(64); got != maxRetryBackoff {
t.Fatalf("floor at attempt 64 = %v, want cap %v", got, maxRetryBackoff)
}
// The live function honours the cap too: at a high attempt the result stays in
// [maxRetryBackoff, maxRetryBackoff+base].
for i := 0; i < 2000; i++ {
got := backoffForAttempt(base, 64)
if got < maxRetryBackoff || got > maxRetryBackoff+base {
t.Fatalf("capped backoff = %v, want within [%v, %v]", got, maxRetryBackoff, maxRetryBackoff+base)
}
}
if !sawJitter {
t.Fatal("jitter never observed across 8 attempts; de-sync jitter is not applied")
}

// A zero/negative base falls back to retryBaseBackoff rather than collapsing to
// a fixed no-backoff.
if got := backoffForAttempt(0, 0); got < retryBaseBackoff || got > 2*retryBaseBackoff {
t.Fatalf("zero-base backoff = %v, want within [%v, %v]", got, retryBaseBackoff, 2*retryBaseBackoff)
}
}