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
59 changes: 59 additions & 0 deletions e2e/scenarios/06-no-change-skip.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: "No Change Skip"
description: "Re-orchestrating with no intervening commit skips the build instead of re-running it"

config:
trunk_branch: main
environments: []
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys: []

steps:
# Step 1: Push a src commit to trunk -> build runs, draft release v0.1.0-rc.0
- name: "Initial feature commit"
action: commit
commit:
message: "feat: add initial feature"
files:
src/app.go: |
package main
func main() {
println("Hello v0.1.0")
}

- name: "Orchestrate after first commit"
action: orchestrate
expect:
state:
prerelease:
sha: commit1
version: "v0.1.0-rc.0"
jobs:
build-app: success
releases:
- tag: "v0.1.0-rc.0"
prerelease: true
draft: true
tags:
exist: ["v0.1.0-rc.0"]

# Step 2: Re-orchestrate with NO new source commit. The only thing that moved
# HEAD since step 1 is cascade's own "chore: update state" finalize commit,
# which touches .github/manifest.yaml and never matches the src/** trigger.
# Change detection now anchors on the build's recorded SHA (and the env-level
# SHA), so the build has nothing new to do and must be SKIPPED rather than
# re-running on every dispatch.
#
# The prerelease SHA and version are intentionally not asserted here: the
# finalize state commit advances HEAD and the rc number recomputes, both of
# which are orthogonal to the no-change-skip fix. The rc tag from step 1 still
# exists, and build-app: skipped is the load-bearing assertion.
- name: "Orchestrate again with no new commit"
action: orchestrate
expect:
jobs:
build-app: skipped
tags:
exist: ["v0.1.0-rc.0"]
111 changes: 111 additions & 0 deletions internal/orchestrate/nochange_proof_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package orchestrate

import (
"testing"

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

// unfixedBuildBaseSHA reproduces the PRE-FIX build base-SHA ladder verbatim, as
// it existed before commit ee432d2 (see `git diff HEAD~1`). The old ladder had
// exactly two rungs for a build: (1) the dependent deploy's state SHA, then
// (2) defaultBase (HEAD~1). There was no per-build state lookup and no
// env-level envState.SHA fallback. This function lets us run the unfixed
// algorithm against the same real git fixture the fixed code runs against,
// proving empirically whether the no-change-skip bug was real.
func (o *Orchestrator) unfixedBuildBaseSHA(envState *config.EnvState, buildName string) string {
defaultBase, _ := o.gitOutput("rev-parse", "HEAD~1")
if defaultBase == "" {
defaultBase, _ = o.gitOutput("rev-list", "--max-parents=0", "HEAD")
}

base := ""
if envState != nil && envState.Deploys != nil {
for _, deploy := range o.cicdFile.Config.Deploys {
if contains(deploy.DependsOn, buildName) {
if ds := envState.Deploys[deploy.Name]; ds != nil && ds.SHA != "" {
base = ds.SHA
break
}
}
}
}
if base == "" {
base = defaultBase
}
return base
}

// TestNoChangeReDispatch_ProvesUnfixedBugAndFix runs BOTH algorithms against one
// real git fixture representing a genuine no-change re-dispatch:
//
// - Repo: HEAD~1 = chore:init (no src), HEAD = feat:add app (src/app.go).
// - State: prerelease.sha == HEAD (the env was already orchestrated at HEAD).
// - There is NO dependent deploy (deploys: []), and NO prior finalize state
// commit sits at HEAD: HEAD is the src commit itself.
//
// This is the exact "same-HEAD no-change dispatch with no intervening state
// commit" scenario. We assert the OBSERVED values:
//
// UNFIXED: base = HEAD~1 (chore:init) -> diff HEAD~1..HEAD touches src/app.go
// -> detectChanges == true -> build RE-RUNS (THE BUG)
// FIXED: base = envState.SHA == HEAD -> diff HEAD..HEAD empty
// -> detectChanges == false -> build SKIPS (THE FIX)
func TestNoChangeReDispatch_ProvesUnfixedBugAndFix(t *testing.T) {
repoDir, headSHA := initRepo(t)
manifestPath := writeManifest(t, repoDir, headSHA) // state.sha == HEAD

orch, err := NewOrchestrator(manifestPath, "ci", "prerelease")
if err != nil {
t.Fatalf("NewOrchestrator: %v", err)
}

envState := orch.cicdFile.State["prerelease"]
if envState == nil || envState.SHA != headSHA {
t.Fatalf("fixture invariant broken: envState.SHA=%q want %q", func() string {
if envState == nil {
return "<nil>"
}
return envState.SHA
}(), headSHA)
}

// --- Sanity: confirm HEAD~1 carries no src/** but HEAD~1..HEAD does. ---
headParent, _ := orch.gitOutput("rev-parse", "HEAD~1")
t.Logf("OBSERVED headSHA = %s", headSHA)
t.Logf("OBSERVED HEAD~1 = %s", headParent)
t.Logf("OBSERVED envState.SHA = %s", envState.SHA)

triggers := orch.cicdFile.Config.Builds[0].Triggers // ["src/**"]

// --- UNFIXED algorithm ---
unfixedBase := orch.unfixedBuildBaseSHA(envState, "app")
unfixedRuns := orch.detectChanges(unfixedBase, headSHA, triggers)
t.Logf("UNFIXED base=%s detectChanges(base..HEAD)=%v", unfixedBase, unfixedRuns)

if unfixedBase != headParent {
t.Errorf("UNFIXED base = %s, expected HEAD~1 (%s): the bug requires the ladder to fall to HEAD~1", unfixedBase, headParent)
}
if !unfixedRuns {
t.Errorf("UNFIXED RunBuilds[app] = false, expected TRUE: the unfixed ladder anchors on HEAD~1 which still contains the src commit, so the build re-runs on a no-change dispatch (the bug was NOT reproduced)")
} else {
t.Logf("PROVEN: unfixed code RE-RUNS the build on a genuine no-change dispatch (RunBuilds[app]=true)")
}

// --- FIXED algorithm (the real Setup path) ---
res, err := orch.Setup(headSHA)
if err != nil {
t.Fatalf("Setup: %v", err)
}
fixedBase := res.BaseSHAs["build_app"]
t.Logf("FIXED base=%s RunBuilds[app]=%v", fixedBase, res.RunBuilds["app"])

if fixedBase != headSHA {
t.Errorf("FIXED base = %s, expected envState.SHA == HEAD (%s)", fixedBase, headSHA)
}
if res.RunBuilds["app"] {
t.Errorf("FIXED RunBuilds[app] = true, expected FALSE: the fix should skip the build on a no-change dispatch")
} else {
t.Logf("PROVEN: fixed code SKIPS the build on a genuine no-change dispatch (RunBuilds[app]=false)")
}
}
67 changes: 67 additions & 0 deletions internal/orchestrate/nochange_reconcile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package orchestrate

import (
"path/filepath"
"testing"
)

// TestReconcile_StateCommitAtHead_UnfixedAlsoSkips proves the masking case that
// explains why the bug was invisible in the normal harness/live-fleet flow.
//
// Scenario: a prior finalize HAS run and pushed a "chore: update state" commit,
// so the commit graph on the next dispatch is:
//
// HEAD = chore: update state (manifest.yaml only, no src/**)
// HEAD~1 = feat: add app (src/app.go)
//
// dispatch fires at GITHUB_SHA == HEAD (the state commit). With the UNFIXED
// ladder, base = HEAD~1 = the src commit, and diff(HEAD~1..HEAD) touches only
// manifest.yaml, which does NOT match src/** -> build SKIPS even without the
// fix. This is why a same-graph re-dispatch through finalize never reproduced
// the bug: the intervening state commit at HEAD masks it.
//
// Therefore the live-fleet failure ("a no-change orchestrate cut a prerelease
// and Build(app) succeeded") can only be a dispatch where NO state commit sits
// at HEAD - i.e. GITHUB_SHA was the src commit itself (first-ever dispatch, a
// failed/never-run prior finalize, or a manual re-dispatch at the src SHA).
func TestReconcile_StateCommitAtHead_UnfixedAlsoSkips(t *testing.T) {
repoDir, srcHead := initRepo(t) // HEAD = feat:add app (src/app.go)

// Simulate a prior finalize: write the manifest with state.sha = srcHead and
// commit it as a manifest-only "chore: update state" commit. That commit is
// now HEAD; the src commit becomes HEAD~1.
writeManifest(t, repoDir, srcHead)
runGit(t, repoDir, "add", ".github/manifest.yaml")
runGit(t, repoDir, "commit", "-m", "chore: update state for prerelease [skip ci]")
stateHead := runGit(t, repoDir, "rev-parse", "HEAD")

manifestPath := filepath.Join(repoDir, ".github", "manifest.yaml")
orch, err := NewOrchestrator(manifestPath, "ci", "prerelease")
if err != nil {
t.Fatalf("NewOrchestrator: %v", err)
}
envState := orch.cicdFile.State["prerelease"]
triggers := orch.cicdFile.Config.Builds[0].Triggers

headParent, _ := orch.gitOutput("rev-parse", "HEAD~1")
t.Logf("OBSERVED stateHead (HEAD) = %s", stateHead)
t.Logf("OBSERVED HEAD~1 (srcHead) = %s", headParent)

// Confirm HEAD is manifest-only.
changed, _ := orch.gitOutput("diff", "--name-only", headParent, stateHead)
t.Logf("OBSERVED diff(HEAD~1..HEAD) files = %q", changed)

// UNFIXED ladder: base = HEAD~1 = srcHead. Dispatch at GITHUB_SHA = stateHead.
unfixedBase := orch.unfixedBuildBaseSHA(envState, "app")
unfixedRuns := orch.detectChanges(unfixedBase, stateHead, triggers)
t.Logf("UNFIXED base=%s detectChanges(base..stateHead)=%v", unfixedBase, unfixedRuns)

if unfixedBase != headParent {
t.Errorf("UNFIXED base = %s, want HEAD~1 (%s)", unfixedBase, headParent)
}
if unfixedRuns {
t.Errorf("UNFIXED RunBuilds[app] = true; expected FALSE: with a state commit at HEAD the masking should make even the unfixed code skip")
} else {
t.Logf("RECONCILED: with a state commit at HEAD, the UNFIXED code ALSO skips (diff is manifest-only). The bug is masked whenever finalize pushed a state commit before re-dispatch.")
}
}
Loading
Loading