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

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

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

// TestSimulateActionsPreviewPlans builds the real cascade CLI and runs
// `cascade simulate promote|rollback|release` against a three-environment
// manifest, asserting each what-if renders its state diff and ordered effect
// sequence and leaves the manifest byte-identical. Together with the hotfix
// chain test this gives every simulate subcommand a binary-level scenario.
//
// The what-if simulator is in-process, so unlike the act + gitea scenarios this
// exercises the shipped binary end to end (build the CLI, run it against a real
// manifest, parse stdout) with no containers, and therefore runs under -short.
func TestSimulateActionsPreviewPlans(t *testing.T) {
t.Parallel()

projectRoot, err := filepath.Abs("..")
require.NoError(t, err, "resolve project root")

// Build the CLI for the host so the subtests can run it directly.
binDir := t.TempDir()
bin := filepath.Join(binDir, "cascade")
build := exec.Command("go", "build", "-o", bin, "./cmd/cascade")
build.Dir = projectRoot
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build cascade CLI: %v\n%s", err, out)
}

// A dev -> uat -> prod ladder: dev carries a newer rc than uat (so a
// promotion has real work), and prod carries a deploy-history ring snapshot
// (so a rollback has a real prior target).
manifest := `ci:
config:
trunk_branch: main
environments:
- dev
- uat
- prod
state:
dev:
sha: devbase00000000000000000000000000000000c
version: v1.2.0-rc.3
committed_at: "2026-01-03T10:00:00Z"
committed_by: seed
uat:
sha: uatbase0000000000000000000000000000000a
version: v1.1.0-rc.1
committed_at: "2026-01-02T10:00:00Z"
committed_by: seed
prod:
sha: prodnew00000000000000000000000000000000d
version: v1.0.1
committed_at: "2026-01-04T10:00:00Z"
committed_by: seed
previous:
- sha: prodold00000000000000000000000000000000e
version: v1.0.0
committed_at: "2026-01-01T10:00:00Z"
committed_by: seed
`
manifestPath := filepath.Join(t.TempDir(), "manifest.yaml")
require.NoError(t, os.WriteFile(manifestPath, []byte(manifest), 0o600))

// runSimulate runs one subcommand against the shared manifest and asserts
// the record-only engine left the manifest bytes untouched.
runSimulate := func(t *testing.T, args ...string) string {
t.Helper()
run := exec.Command(bin, append([]string{"simulate"}, args...)...)
out, err := run.CombinedOutput()
require.NoErrorf(t, err, "simulate %s failed: %s", strings.Join(args, " "), out)

after, err := os.ReadFile(manifestPath)
require.NoError(t, err)
require.Equal(t, manifest, string(after),
"a simulation must never mutate the on-disk manifest")
return string(out)
}

t.Run("promote", func(t *testing.T) {
got := runSimulate(t, "promote", "--config", manifestPath)

require.Contains(t, got, "Simulating: promote (mode=default)")

// State diff: uat advances to dev's rc, and the release row appears.
require.Contains(t, got, "v1.1.0-rc.1 -> v1.2.0-rc.3",
"uat must advance to the rc dev currently holds")
require.Contains(t, got, "uatbase -> devbase")
require.Contains(t, got, "(none) -> v1.1.0",
"promoting past uat must surface the release the crossing publishes")

// Effects: deploy before state write before release publish.
require.Contains(t, got, "deploy uat from dev")
require.Contains(t, got, "release publish v1.1.0 (rc v1.1.0-rc.1, sha uatbase)")
deployIdx := strings.Index(got, "deploy uat from dev")
writeIdx := strings.Index(got, "write state uat")
publishIdx := strings.Index(got, "release publish v1.1.0")
require.GreaterOrEqual(t, deployIdx, 0)
require.GreaterOrEqual(t, writeIdx, 0)
require.Greater(t, writeIdx, deployIdx, "state is written only after the deploy effect")
require.Greater(t, publishIdx, writeIdx, "the release publishes only after state is written")
})

t.Run("rollback", func(t *testing.T) {
got := runSimulate(t, "rollback", "--env", "prod", "--config", manifestPath)

require.Contains(t, got, "Simulating: rollback (env=prod, to=previous)")

// The target resolves from the deploy-history ring, not trunk state.
require.Contains(t, got, "v1.0.1 -> v1.0.0", "prod must revert to the ring snapshot")
require.Contains(t, got, "prodnew -> prodold")
require.Contains(t, got, "revert prod (to sha prodold, version v1.0.0 (from previous-ring))")
require.Contains(t, got, "write state prod (sha prodold, version v1.0.0)")
require.Contains(t, got, "no -> yes",
"a rollback must mark the environment diverged off trunk")
})

t.Run("rollback without a prior target fails", func(t *testing.T) {
run := exec.Command(bin, "simulate", "rollback", "--env", "uat", "--config", manifestPath)
out, err := run.CombinedOutput()
require.Error(t, err, "uat has no deploy history, so the rollback what-if must fail")
require.Contains(t, string(out), "no prior version to roll back to")
})

t.Run("rollback of the first environment is refused", func(t *testing.T) {
run := exec.Command(bin, "simulate", "rollback", "--env", "dev", "--config", manifestPath)
out, err := run.CombinedOutput()
require.Error(t, err, "the first environment tracks trunk and is never rolled back")
require.Contains(t, string(out), "Revert it with a merge to the trunk branch")
})

t.Run("release", func(t *testing.T) {
got := runSimulate(t, "release", "--config", manifestPath)

require.Contains(t, got, "Simulating: release (prerelease/publish crossing)")
require.Contains(t, got, "(none) -> v1.1.0",
"the crossing must strip uat's rc suffix into the published version")
require.Contains(t, got, "release publish v1.1.0 (rc v1.1.0-rc.1, sha uatbase)")
})
}
246 changes: 246 additions & 0 deletions internal/changes/detect_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package changes

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

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

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

// nullSHA is the all-zero base SHA GitHub sends for a new branch's first push;
// Detect treats it as "everything at head changed".
const nullSHA = "0000000000000000000000000000000000000000"

// newDetectRepo initializes a git repository in a temp directory and changes
// the working directory to it for the duration of the test, since
// git.GetChangedFiles runs against the process working directory. It returns
// the SHAs of a four-commit history covering source, infra, and docs changes.
func newDetectRepo(t *testing.T) (base, srcChange, infraChange, docsChange string) {
t.Helper()

dir := t.TempDir()
orig, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(dir))
t.Cleanup(func() {
require.NoError(t, os.Chdir(orig))
})

runGit := func(args ...string) {
t.Helper()
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
commit := func(name, message string) string {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(name), 0o750))
require.NoError(t, os.WriteFile(name, []byte(message+"\n"), 0o600))
runGit("add", name)
runGit("commit", "-m", message)
out, err := exec.Command("git", "rev-parse", "HEAD").Output()
require.NoError(t, err)
return strings.TrimSpace(string(out))
}

runGit("init")
runGit("config", "user.email", "test@example.com")
runGit("config", "user.name", "Test User")
runGit("config", "commit.gpgsign", "false")

base = commit("README.md", "chore: init")
srcChange = commit("src/app/main.go", "feat: app change")
infraChange = commit("infra/deploy.yaml", "feat: infra change")
docsChange = commit("docs/guide.md", "docs: guide")
return base, srcChange, infraChange, docsChange
}

// detectConfig declares one path-scoped build, one negation-filtered build, one
// path-scoped deploy, and one trigger-less (always-on) deploy, so Detect's
// per-build and per-deploy fan-out is observable.
func detectConfig() *config.TrunkConfig {
return &config.TrunkConfig{
TrunkBranch: "main",
Builds: []config.BuildConfig{
{Name: "app", Triggers: []string{"src/**"}},
{Name: "everything-but-docs", Triggers: []string{"**", "!docs/**", "!**/*.md"}},
},
Deploys: []config.DeployConfig{
{Name: "infra", Triggers: []string{"infra/**"}},
{Name: "always", Triggers: []string{}},
},
}
}

func TestDetect_FansOutTriggeredBuildsAndDeploys(t *testing.T) {
base, srcChange, infraChange, docsChange := newDetectRepo(t)
cfg := detectConfig()

tests := []struct {
name string
baseSHA string
headSHA string
wantChanged []string
wantBuilds []string
wantDeploys []string
wantHasFiles bool
}{
{
name: "source change triggers path build, catch-all build, and always deploy",
baseSHA: base,
headSHA: srcChange,
wantChanged: []string{"src/app/main.go"},
wantBuilds: []string{"app", "everything-but-docs"},
wantDeploys: []string{"always"},
wantHasFiles: true,
},
{
name: "infra change triggers infra deploy but not the app build",
baseSHA: srcChange,
headSHA: infraChange,
wantChanged: []string{"infra/deploy.yaml"},
wantBuilds: []string{"everything-but-docs"},
wantDeploys: []string{"infra", "always"},
wantHasFiles: true,
},
{
name: "docs-only change is excluded by negation yet still counts as a change",
baseSHA: infraChange,
headSHA: docsChange,
wantChanged: []string{"docs/guide.md"},
wantBuilds: []string{},
wantDeploys: []string{"always"},
wantHasFiles: true,
},
{
name: "identical SHAs mean no changes and nothing triggered",
baseSHA: docsChange,
headSHA: docsChange,
wantChanged: nil,
wantBuilds: []string{},
wantDeploys: []string{},
wantHasFiles: false,
},
{
name: "multi-commit range accumulates every changed file",
baseSHA: base,
headSHA: infraChange,
wantChanged: []string{"infra/deploy.yaml", "src/app/main.go"},
wantBuilds: []string{"app", "everything-but-docs"},
wantDeploys: []string{"infra", "always"},
wantHasFiles: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Detect(cfg, tt.baseSHA, tt.headSHA)
require.NoError(t, err)

assert.Equal(t, tt.wantHasFiles, result.HasChanges)
assert.ElementsMatch(t, tt.wantChanged, result.ChangedFiles)
assert.Equal(t, tt.wantBuilds, result.TriggeredBuilds)
assert.Equal(t, tt.wantDeploys, result.TriggeredDeploys)
})
}
}

func TestDetect_NullBaseSHATreatsWholeTreeAsChanged(t *testing.T) {
_, srcChange, _, _ := newDetectRepo(t)

result, err := Detect(detectConfig(), nullSHA, srcChange)
require.NoError(t, err)

assert.True(t, result.HasChanges)
assert.ElementsMatch(t, []string{"README.md", "src/app/main.go"}, result.ChangedFiles,
"a null base means every file at head is a change")
assert.Equal(t, []string{"app", "everything-but-docs"}, result.TriggeredBuilds)
assert.Equal(t, []string{"always"}, result.TriggeredDeploys)
}

func TestDetect_UnknownSHASurfacesGitError(t *testing.T) {
_, srcChange, _, _ := newDetectRepo(t)

_, err := Detect(detectConfig(), "b8d6a54e6fbbf2a4a89ae0f0b1a76c9bb1e13a00", srcChange)
require.Error(t, err, "an unresolvable base SHA must surface, not read as no changes")
}

func TestNewCommand_RequiresSHAFlags(t *testing.T) {
cmd := NewCommand()
cmd.SetArgs([]string{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "base-sha")
}

func TestDetectChangesCommand_EmitsResultJSON(t *testing.T) {
base, srcChange, _, _ := newDetectRepo(t)

manifest := `ci:
config:
trunk_branch: main
environments:
- dev
builds:
- name: app
workflow: .github/workflows/build.yaml
triggers:
- "src/**"
deploys:
- name: site
workflow: .github/workflows/deploy.yaml
triggers:
- "docs/**"
`
manifestPath := filepath.Join(t.TempDir(), "manifest.yaml")
require.NoError(t, os.WriteFile(manifestPath, []byte(manifest), 0o600))

// runDetectChanges writes to os.Stdout directly; capture it via a pipe.
readEnd, writeEnd, err := os.Pipe()
require.NoError(t, err)
origStdout := os.Stdout
os.Stdout = writeEnd
defer func() { os.Stdout = origStdout }()

cmd := NewCommand()
cmd.SetArgs([]string{"--config", manifestPath, "--base-sha", base, "--head-sha", srcChange})
execErr := cmd.Execute()

require.NoError(t, writeEnd.Close())
os.Stdout = origStdout
var out bytes.Buffer
_, err = out.ReadFrom(readEnd)
require.NoError(t, err)
require.NoError(t, execErr)

var result DetectResult
require.NoError(t, json.Unmarshal(out.Bytes(), &result), "output must be valid JSON: %s", out.String())
assert.True(t, result.HasChanges)
assert.Equal(t, []string{"src/app/main.go"}, result.ChangedFiles)
assert.Equal(t, []string{"app"}, result.TriggeredBuilds)
assert.Equal(t, []string{}, result.TriggeredDeploys)
}

func TestDetectChangesCommand_BadConfigPathErrors(t *testing.T) {
cmd := NewCommand()
cmd.SetArgs([]string{"--config", filepath.Join(t.TempDir(), "missing.yaml"),
"--base-sha", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"--head-sha", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "parsing config")
}
Loading