From d2ec516c54b56bc2c96adabae2557531dfe4c64d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 9 Jul 2026 19:22:20 -0400 Subject: [PATCH] test: cover single-component state write, skip-ci marker, and backoff jitter Signed-off-by: Joshua Temple --- internal/config/scopedstate_single_test.go | 73 +++++++++++++++++++ internal/generate/state_write_test.go | 48 +++++++++++++ internal/git/backoff_test.go | 81 ++++++++++++++++++++++ internal/reset/reset_push_test.go | 62 +++++++++++++++++ internal/statewrite/backoff_test.go | 81 ++++++++++++++++++++++ 5 files changed, 345 insertions(+) create mode 100644 internal/config/scopedstate_single_test.go create mode 100644 internal/git/backoff_test.go create mode 100644 internal/statewrite/backoff_test.go diff --git a/internal/config/scopedstate_single_test.go b/internal/config/scopedstate_single_test.go new file mode 100644 index 00000000..4f49984b --- /dev/null +++ b/internal/config/scopedstate_single_test.go @@ -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. +// shape with no components. 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. shape: no + // components. 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) + } +} diff --git a/internal/generate/state_write_test.go b/internal/generate/state_write_test.go index a0afc52a..277fae6f 100644 --- a/internal/generate/state_write_test.go +++ b/internal/generate/state_write_test.go @@ -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) { diff --git a/internal/git/backoff_test.go b/internal/git/backoff_test.go new file mode 100644 index 00000000..eba0f70e --- /dev/null +++ b/internal/git/backoff_test.go @@ -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) + } +} diff --git a/internal/reset/reset_push_test.go b/internal/reset/reset_push_test.go index 7588be78..54a0ec8e 100644 --- a/internal/reset/reset_push_test.go +++ b/internal/reset/reset_push_test.go @@ -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) { diff --git a/internal/statewrite/backoff_test.go b/internal/statewrite/backoff_test.go new file mode 100644 index 00000000..758a2f2f --- /dev/null +++ b/internal/statewrite/backoff_test.go @@ -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) + } +}