From afc709c46a33bf90ded0c397777716cb4a857cbd Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 18 Jun 2026 14:49:45 -0400 Subject: [PATCH] feat: add cascade verify command to detect workflow drift Signed-off-by: Joshua Temple --- cmd/cascade/main.go | 17 +- docs/src/content/docs/cli-reference.md | 37 ++++ e2e/harness/multistep.go | 19 ++ e2e/harness/runner.go | 81 +++++++++ e2e/scenarios/22-verify-drift.yaml | 49 +++++ internal/generate/actions.go | 28 ++- internal/generate/plan.go | 202 ++++++++++++++++++++ internal/generate/plan_test.go | 181 ++++++++++++++++++ internal/verify/command.go | 41 +++++ internal/verify/verify.go | 170 +++++++++++++++++ internal/verify/verify_test.go | 243 +++++++++++++++++++++++++ 11 files changed, 1058 insertions(+), 10 deletions(-) create mode 100644 e2e/scenarios/22-verify-drift.yaml create mode 100644 internal/generate/plan.go create mode 100644 internal/generate/plan_test.go create mode 100644 internal/verify/command.go create mode 100644 internal/verify/verify.go create mode 100644 internal/verify/verify_test.go diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index 8adce355..95d77144 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" @@ -22,6 +23,7 @@ import ( "github.com/stablekernel/cascade/internal/rollback" "github.com/stablekernel/cascade/internal/schema" "github.com/stablekernel/cascade/internal/status" + "github.com/stablekernel/cascade/internal/verify" versionpkg "github.com/stablekernel/cascade/internal/version" ) @@ -72,6 +74,7 @@ change detection, and changelog generation.`, rootCmd.AddCommand(changelog.NewCommand()) rootCmd.AddCommand(external.NewCommand()) rootCmd.AddCommand(generate.NewCommand()) + rootCmd.AddCommand(verify.NewCommand()) rootCmd.AddCommand(hotfix.NewCommand()) rootCmd.AddCommand(initcmd.NewCommand()) rootCmd.AddCommand(orchestrate.NewCommand()) @@ -86,10 +89,22 @@ change detection, and changelog generation.`, if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + os.Exit(exitCodeFor(err)) } } +// exitCodeFor maps a command error to a process exit code. A command may opt +// into a specific code by returning an error that implements ExitCode() int +// (verify uses this to distinguish drift from an operational failure); every +// other error keeps the default exit code 1. +func exitCodeFor(err error) int { + var ec interface{ ExitCode() int } + if errors.As(err, &ec) { + return ec.ExitCode() + } + return 1 +} + func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index 40296fdb..db0b13c4 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -225,6 +225,43 @@ cascade generate-workflow - **Environment overrides**: applies `env_inputs` per environment - **Publish step**: when `publish:` is configured, the promote workflow dispatches the callback once per build at the boundary where a prerelease becomes a release +### verify + +Check that the committed workflow and action files match what the manifest would currently generate, without writing anything. `verify` is read-only: it never writes files, runs git, or modifies the repository. + +```bash +cascade verify +``` + +`verify` reports drift when a file the manifest would generate is missing on disk, or when its committed bytes differ from the generated bytes. It covers the complete set of files `generate-workflow` emits (orchestrate, promote or release, external-update, validate-check, merge-queue, hotfix, rollback, pr-preview, and the manage-release composite action), so adopters do not need to enumerate files by hand. + +#### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--config`, `-c` | string | auto-detect | Path to manifest file | +| `--manifest-key` | string | `ci` | Top-level key inside the manifest | +| `--action-folder` | string | `manage-release` | Folder for the manage-release composite action | +| `--output`, `-o` | string | `.github/workflows/orchestrate.yaml` | Path of the orchestrate workflow | +| `--promote-output` | string | `.github/workflows/promote.yaml` | Path of the promote workflow | +| `--quiet`, `-q` | bool | false | Suppress the per-file report body; only set the exit code | + +#### Exit codes + +| Exit | Meaning | +|------|---------| +| 0 | No drift: every generated file is present and byte-identical | +| 1 | Drift detected: a generated file is missing or its committed bytes differ | +| 2 | Error: the manifest is missing or invalid, or another operational failure prevented the check from running | + +#### Use in CI + +`verify` replaces a hand-rolled "regenerate and `git diff`" drift check with a single step. A CI job can run `cascade verify` to fail the build whenever committed workflows fall out of sync with the manifest: + +```yaml +- run: cascade verify +``` + ### manage-release Manage GitHub releases. diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index b792781d..4f6cec24 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -86,6 +86,10 @@ type Step struct { // environment's divergence fields in the live manifest mid-scenario without // running any workflow. StageDivergence *StageDivergenceStep `yaml:"stage_divergence,omitempty"` + // Verify configures a "verify" action: a read-only `cascade verify` run that + // asserts the committed workflows match the manifest, exercising verify's + // exit-code contract. + Verify *VerifyStep `yaml:"verify,omitempty"` // ExpectFailure marks a step whose workflow is expected to conclude in // failure (for example an orchestrate run whose build exits non-zero). When // set, a failure conclusion is the success path and a success conclusion is @@ -193,6 +197,21 @@ type RollbackStep struct { ExpectFailure bool `yaml:"expect_failure,omitempty"` } +// VerifyStep defines a verify action: a read-only `cascade verify` run in the +// repo that compares the committed workflow and action files against what the +// manifest would generate. Regenerate, when set, runs `cascade generate-workflow +// -f` first so verify checks pristine generated output rather than the harness's +// localized copies. Mutate optionally overwrites one generated file with the +// given content before verifying, so a scenario can drive the drift path. +// ExpectExit is the exit code `cascade verify` must return (0 = no drift, +// non-zero = drift). +type VerifyStep struct { + Regenerate bool `yaml:"regenerate,omitempty"` + MutatePath string `yaml:"mutate_path,omitempty"` + MutateAppend string `yaml:"mutate_append,omitempty"` + ExpectExit int `yaml:"expect_exit"` +} + // StepExpect defines expected outcomes for a step type StepExpect struct { State map[string]*StateExpect `yaml:"state,omitempty"` diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index fc2d7b31..838dcdf8 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -121,6 +121,13 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if step.Rollback.Environment == "" { return fmt.Errorf("step %d (%s): rollback requires environment", i, step.Name) } + case "verify": + if step.Verify == nil { + return fmt.Errorf("step %d (%s): verify action requires verify config", i, step.Name) + } + if step.Verify.MutatePath != "" && step.Verify.MutateAppend == "" { + return fmt.Errorf("step %d (%s): verify mutate_path requires mutate_append", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -355,11 +362,85 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err return r.executeStageDivergence(ctx, step.StageDivergence) case "rollback": return r.executeRollback(ctx, step.Rollback, config) + case "verify": + return r.executeVerify(ctx, step.Verify) default: return fmt.Errorf("unknown action: %s", step.Action) } } +// executeVerify runs `cascade verify` in the synced repo and asserts the exit +// code matches the step's ExpectExit. When Regenerate is set it first runs +// `cascade generate-workflow -f` so verify checks pristine generated output +// rather than the harness's localized copies. When MutatePath is set it appends +// MutateAppend to that file before verifying, driving the drift path. The whole +// step is read-through-the-CLI and never asserts on workflow execution. +func (r *Runner) executeVerify(ctx context.Context, step *VerifyStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Logf(" Would run cascade verify (expect exit %d, no harness)", step.ExpectExit) + return nil + } + + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("verify: failed to sync repo: %w", err) + } + + if step.Regenerate { + regenCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade generate-workflow -f"} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, regenCmd) + if err != nil { + return fmt.Errorf("verify: regenerate exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("verify: regenerate failed (exit %d): %s", exitCode, out.String()) + } + } + + if step.MutatePath != "" { + mutateCmd := []string{"bash", "-c", fmt.Sprintf( + "cd /tmp/repo && printf '%%s' %s >> %s", + shellQuote(step.MutateAppend), shellQuote(step.MutatePath), + )} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, mutateCmd) + if err != nil { + return fmt.Errorf("verify: mutate exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("verify: mutate failed (exit %d): %s", exitCode, out.String()) + } + } + + verifyCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade verify"} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, verifyCmd) + if err != nil { + return fmt.Errorf("verify: exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + r.t.Logf(" Verify: exit=%d (expected %d): %s", exitCode, step.ExpectExit, out.String()) + + if exitCode != step.ExpectExit { + return fmt.Errorf("verify: expected exit %d, got %d: %s", step.ExpectExit, exitCode, out.String()) + } + return nil +} + +// shellQuote wraps a string in single quotes for safe interpolation into a +// bash -c command, escaping embedded single quotes. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + // executeCommit creates a commit func (r *Runner) executeCommit(ctx context.Context, commit *CommitStep) error { // Track commit reference diff --git a/e2e/scenarios/22-verify-drift.yaml b/e2e/scenarios/22-verify-drift.yaml new file mode 100644 index 00000000..0b3f796f --- /dev/null +++ b/e2e/scenarios/22-verify-drift.yaml @@ -0,0 +1,49 @@ +name: "Verify Drift Detection" +description: | + Exercises the read-only `cascade verify` command end to end. After generating + workflows from a two-environment manifest, verify against pristine generated + output reports no drift (exit 0). Mutating a committed workflow makes verify + report drift (exit non-zero). Regenerating restores the workflows and verify + is clean again (exit 0). + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: cdk + workflow: deploy.yaml + triggers: ["cdk/**"] + +steps: + - name: "Initial feature commit" + action: commit + commit: + message: "feat: add app feature" + files: + src/app.go: | + package main + + func main() {} + + - name: "Verify clean against pristine generated output" + action: verify + verify: + regenerate: true + expect_exit: 0 + + - name: "Mutate a generated workflow; verify reports drift" + action: verify + verify: + mutate_path: ".github/workflows/orchestrate.yaml" + mutate_append: "\n# drift\n" + expect_exit: 1 + + - name: "Regenerate restores workflows; verify is clean again" + action: verify + verify: + regenerate: true + expect_exit: 0 diff --git a/internal/generate/actions.go b/internal/generate/actions.go index 19c2c144..9562431c 100644 --- a/internal/generate/actions.go +++ b/internal/generate/actions.go @@ -9,19 +9,29 @@ import ( "github.com/stablekernel/cascade/internal/config" ) -// GenerateLocalActions creates the local action files in the user's repo -// Uses cfg.GetActionFolder() for the folder name (default: "manage-release") -func GenerateLocalActions(baseDir string, cfg *config.TrunkConfig) error { +// RenderLocalActions returns the composite action file the manifest would +// generate, paired with its rendered content, without writing anything to disk. +// The path is baseDir/.github/actions//action.yaml where is +// cfg.GetActionFolder() (default: "manage-release"). +func RenderLocalActions(baseDir string, cfg *config.TrunkConfig) (PlannedFile, error) { actionFolder := cfg.GetActionFolder() - actionsDir := filepath.Join(baseDir, ".github", "actions", actionFolder) - if err := os.MkdirAll(actionsDir, 0755); err != nil { - return fmt.Errorf("creating actions directory: %w", err) + actionPath := filepath.Join(baseDir, ".github", "actions", actionFolder, "action.yaml") + return PlannedFile{Path: actionPath, Content: generateManageReleaseAction()}, nil +} + +// GenerateLocalActions creates the local action files in the user's repo. +// Uses cfg.GetActionFolder() for the folder name (default: "manage-release"). +func GenerateLocalActions(baseDir string, cfg *config.TrunkConfig) error { + action, err := RenderLocalActions(baseDir, cfg) + if err != nil { + return err } - actionPath := filepath.Join(actionsDir, "action.yaml") - content := generateManageReleaseAction() + if err := os.MkdirAll(filepath.Dir(action.Path), 0755); err != nil { + return fmt.Errorf("creating actions directory: %w", err) + } - if err := os.WriteFile(actionPath, []byte(content), 0644); err != nil { + if err := os.WriteFile(action.Path, []byte(action.Content), 0644); err != nil { return fmt.Errorf("writing action file: %w", err) } diff --git a/internal/generate/plan.go b/internal/generate/plan.go new file mode 100644 index 00000000..bd7d9d11 --- /dev/null +++ b/internal/generate/plan.go @@ -0,0 +1,202 @@ +package generate + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/stablekernel/cascade/internal/config" +) + +// PlannedFile is a single workflow or action file the manifest would generate, +// paired with its rendered content. Path mirrors the exact location the generate +// command writes to: relative paths for the workflow files (resolved against the +// current working directory) and an absolute path under baseDir for the composite +// action. +type PlannedFile struct { + Path string + Content string +} + +// PlanOptions configures a Plan run. The fields mirror the generate-workflow +// flags that affect which files are emitted and where, so a Plan reproduces the +// generate command's full-set output without touching the filesystem. +type PlanOptions struct { + ConfigPath string + ManifestKey string + ActionFolder string + OutputPath string + PromoteOutputPath string +} + +// Plan resolves the manifest and returns the complete set of files the generate +// command would write for it, each paired with its rendered content. The result +// is sorted by Path and is deterministic across calls. Plan never touches the +// filesystem beyond reading the manifest and the reusable-workflow stubs the +// generators inspect; it performs no writes, no directory creation, and no git +// invocation. +// +// A parse failure, a missing manifest, or a config validation failure returns a +// non-nil error. These are operational failures distinct from any drift a caller +// may compute by comparing the returned content to bytes on disk. +func Plan(opts PlanOptions) ([]PlannedFile, error) { + // Determine config path - auto-detect if not specified. + configPath := opts.ConfigPath + if configPath == "" { + configPath = config.FindConfigFile("") + } + + cfg, err := config.ParseWithKey(configPath, opts.ManifestKey) + if err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + // Parse the full manifest (including state) so generators can resolve + // cascade-owned ${{ state.. }} input references. State is + // optional; absence is not an error. + var manifestState map[string]*config.EnvState + if full, ferr := config.ParseManifestFile(configPath, opts.ManifestKey); ferr == nil { + manifestState = full.State + } + + // Override action folder if specified on command line. + if opts.ActionFolder != "" && opts.ActionFolder != "manage-release" { + cfg.ActionFolder = opts.ActionFolder + } + + if errs := config.Validate(cfg); len(errs) > 0 { + return nil, fmt.Errorf("config validation failed: %s", errs[0]) + } + + baseDir := resolveBaseDir(configPath) + + outputPath := opts.OutputPath + if outputPath == "" { + outputPath = ".github/workflows/orchestrate.yaml" + } + promoteOutputPath := opts.PromoteOutputPath + if promoteOutputPath == "" { + promoteOutputPath = ".github/workflows/promote.yaml" + } + + var planned []PlannedFile + + // 1. orchestrate -> outputPath (verify always treats the full set as enabled). + orchestrateGen := NewGenerator(cfg, baseDir) + orchestrateGen.SetState(manifestState) + content, err := orchestrateGen.Generate() + if err != nil { + return nil, fmt.Errorf("generating orchestrate workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: outputPath, Content: content}) + + // 2. promote (multi-env) or release (single-env) -> promoteOutputPath. + if cfg.IsSingleEnvironment() { + content, err = NewReleaseGenerator(cfg, baseDir).Generate() + if err != nil { + return nil, fmt.Errorf("generating release workflow: %w", err) + } + } else { + promoteGen := NewPromoteGenerator(cfg, baseDir) + promoteGen.SetState(manifestState) + content, err = promoteGen.Generate() + if err != nil { + return nil, fmt.Errorf("generating promote workflow: %w", err) + } + } + planned = append(planned, PlannedFile{Path: promoteOutputPath, Content: content}) + + // 3. external-update -> .github/workflows/external-update.yaml when primary. + if cfg.IsPrimary() { + content, err = NewExternalUpdateGenerator(cfg, baseDir).Generate() + if err != nil { + return nil, fmt.Errorf("generating external-update workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: ".github/workflows/external-update.yaml", Content: content}) + } + + // 4. validate-check -> .github/workflows/cascade-validate.yaml when enabled. + if gen := NewValidateCheckGenerator(cfg, baseDir); gen.Enabled() { + content, err = gen.Generate() + if err != nil { + return nil, fmt.Errorf("generating validate-check workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-validate.yaml", Content: content}) + } + + // 5. merge-queue -> .github/workflows/cascade-merge-queue.yaml when enabled. + if gen := NewMergeQueueGenerator(cfg, baseDir); gen.Enabled() { + content, err = gen.Generate() + if err != nil { + return nil, fmt.Errorf("generating merge-queue workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-merge-queue.yaml", Content: content}) + } + + // 6. hotfix -> .github/workflows/cascade-hotfix.yaml when enabled. + if gen := NewHotfixGenerator(cfg, baseDir); gen.Enabled() { + content, err = gen.Generate() + if err != nil { + return nil, fmt.Errorf("generating hotfix workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-hotfix.yaml", Content: content}) + } + + // 7. rollback -> .github/workflows/cascade-rollback.yaml when enabled. + if gen := NewRollbackGenerator(cfg, baseDir); gen.Enabled() { + content, err = gen.Generate() + if err != nil { + return nil, fmt.Errorf("generating rollback workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-rollback.yaml", Content: content}) + } + + // 8. pr-preview -> .github/workflows/cascade-pr-preview.yaml when enabled. + if cfg.PRPreview != nil && cfg.PRPreview.Enabled { + content, err = NewPRPreviewGenerator(cfg, baseDir).Generate() + if err != nil { + return nil, fmt.Errorf("generating pr-preview workflow: %w", err) + } + planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-pr-preview.yaml", Content: content}) + } + + // 9. composite action -> baseDir/.github/actions//action.yaml. + action, err := RenderLocalActions(baseDir, cfg) + if err != nil { + return nil, fmt.Errorf("rendering local actions: %w", err) + } + planned = append(planned, action) + + sort.Slice(planned, func(i, j int) bool { + return planned[i].Path < planned[j].Path + }) + + return planned, nil +} + +// ResolveBaseDir reports the repo root the generate command resolves workflow +// paths against for the given config path: the config's directory, promoted one +// level up when the config lives in .github/. Callers that compare planned files +// (which carry relative workflow paths) against bytes on disk use this to anchor +// those relative paths to the manifest's repo root instead of the process +// working directory. +func ResolveBaseDir(configPath string) string { + return resolveBaseDir(configPath) +} + +// resolveBaseDir reproduces the generate command's base-directory resolution: +// the config's directory, promoted one level up when the config lives in +// .github/, so workflow paths resolve against the repo root. +func resolveBaseDir(configPath string) string { + configDir := filepath.Dir(configPath) + if !filepath.IsAbs(configDir) { + cwd, _ := os.Getwd() + configDir = filepath.Join(cwd, configDir) + } + baseDir := configDir + if filepath.Base(configDir) == ".github" { + baseDir = filepath.Dir(configDir) + } + return baseDir +} diff --git a/internal/generate/plan_test.go b/internal/generate/plan_test.go new file mode 100644 index 00000000..e4735662 --- /dev/null +++ b/internal/generate/plan_test.go @@ -0,0 +1,181 @@ +package generate + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// chdir changes the working directory to dir for the duration of the test, +// restoring the original on cleanup. The generate path and Plan both resolve +// relative workflow paths against the working directory, so a test comparing +// their emitted sets must run with the repo root as the working directory. +func chdir(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(orig)) + }) +} + +// writePlanManifest writes the determinism manifest plus its reusable-workflow +// stubs into a temp repo and returns the repo root. Plan and the existing +// generate path are both rooted at this directory so their emitted file sets can +// be compared byte for byte. +func writePlanManifest(t *testing.T) string { + t.Helper() + dir := writeDeterminismWorkflows(t) + + cfg := determinismConfig() + manifest := map[string]any{ + config.DefaultManifestKey: config.CICDFile{Config: cfg}, + } + body, err := yaml.Marshal(manifest) + require.NoError(t, err) + + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".github", "manifest.yaml"), body, 0o644)) + return dir +} + +// walkGeneratedFiles walks every workflow and action file the generate path +// wrote under dir, returning a path->content map keyed by the path relative to +// dir so it can be compared to Plan's emitted set. +func walkGeneratedFiles(t *testing.T, dir string) map[string]string { + t.Helper() + out := make(map[string]string) + roots := []string{ + filepath.Join(dir, ".github", "workflows"), + filepath.Join(dir, ".github", "actions"), + } + for _, root := range roots { + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, rerr := filepath.Rel(dir, path) + require.NoError(t, rerr) + // Skip the reusable-workflow stubs the test laid down by hand. + switch rel { + case filepath.Join(".github", "workflows", "image-build.yaml"), + filepath.Join(".github", "workflows", "bundle-build.yaml"), + filepath.Join(".github", "workflows", "deploy.yaml"): + return nil + } + content, rerr := os.ReadFile(path) + require.NoError(t, rerr) + out[rel] = string(content) + return nil + }) + require.NoError(t, err) + } + return out +} + +// TestPlan_MatchesGeneratedBytes proves Plan returns a {Path, Content} set that +// is byte-identical to what the existing generate path writes to disk for a +// representative multi-environment manifest. This is the keystone guard for the +// verify refactor: if Plan ever diverges from generate, verify would falsely +// report drift. +func TestPlan_MatchesGeneratedBytes(t *testing.T) { + dir := writePlanManifest(t) + chdir(t, dir) + // Resolve symlinks so dir matches the path the generator derives from + // os.Getwd (on macOS /var is a symlink to /private/var); otherwise the + // absolute composite-action path would not be relative to dir. + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + dir = resolved + + // Run the existing generate path into the repo, using the relative paths the + // CLI uses by default so the hardcoded workflow paths resolve identically. + opts := generateOptions{ + configPath: ".github/manifest.yaml", + manifestKey: config.DefaultManifestKey, + actionFolder: "manage-release", + outputPath: ".github/workflows/orchestrate.yaml", + promoteOutputPath: ".github/workflows/promote.yaml", + force: true, + } + require.NoError(t, runGenerateWorkflow(opts)) + + written := walkGeneratedFiles(t, dir) + + planned, err := Plan(PlanOptions{ + ConfigPath: ".github/manifest.yaml", + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: ".github/workflows/orchestrate.yaml", + PromoteOutputPath: ".github/workflows/promote.yaml", + }) + require.NoError(t, err) + require.NotEmpty(t, planned) + + // Plan must be sorted by Path. + paths := make([]string, len(planned)) + for i, p := range planned { + paths[i] = p.Path + } + sorted := append([]string(nil), paths...) + sort.Strings(sorted) + require.Equal(t, sorted, paths, "Plan output must be sorted by Path") + + // Compare Plan's emitted set to the bytes generate wrote. Plan paths are a + // mix of relative (workflows) and absolute (composite action under baseDir); + // normalize both against dir for comparison. + plannedByRel := make(map[string]string, len(planned)) + for _, p := range planned { + abs := p.Path + if !filepath.IsAbs(abs) { + abs = filepath.Join(dir, abs) + } + rel, rerr := filepath.Rel(dir, abs) + require.NoError(t, rerr) + plannedByRel[rel] = p.Content + } + + require.Equal(t, len(written), len(plannedByRel), + "Plan emitted a different number of files than generate wrote") + for rel, want := range written { + got, ok := plannedByRel[rel] + require.Truef(t, ok, "Plan did not emit %s that generate wrote", rel) + require.Equalf(t, want, got, "Plan content for %s differs from generated bytes", rel) + } +} + +// TestPlan_Deterministic asserts Plan returns an identical set across repeated +// calls in one process, guarding the #174 determinism contract through the +// plan-set boundary that verify consumes. +func TestPlan_Deterministic(t *testing.T) { + dir := writePlanManifest(t) + chdir(t, dir) + + planOpts := PlanOptions{ + ConfigPath: ".github/manifest.yaml", + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: ".github/workflows/orchestrate.yaml", + PromoteOutputPath: ".github/workflows/promote.yaml", + } + + baseline, err := Plan(planOpts) + require.NoError(t, err) + require.NotEmpty(t, baseline) + + const runs = 10 + for i := 1; i < runs; i++ { + got, gerr := Plan(planOpts) + require.NoError(t, gerr) + require.Equalf(t, baseline, got, "Plan run %d diverged from baseline", i) + } +} diff --git a/internal/verify/command.go b/internal/verify/command.go new file mode 100644 index 00000000..b9609176 --- /dev/null +++ b/internal/verify/command.go @@ -0,0 +1,41 @@ +package verify + +import ( + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" +) + +// NewCommand creates the verify command, a read-only drift check that compares +// committed workflow and action files against what the manifest would generate. +func NewCommand() *cobra.Command { + var o Options + + cmd := &cobra.Command{ + Use: "verify", + Short: "Check committed workflows against the manifest for drift", + Long: `Compare the committed GitHub Actions workflow and action files against what +the manifest would currently generate, without writing anything. + +verify reports drift when a file the manifest would generate is missing on disk +or its committed bytes differ from the generated bytes. It exits non-zero on +drift and zero when the committed files are in sync, so it can replace a +hand-rolled "regenerate and git diff" CI step with a single command. A missing +or invalid manifest is reported as an operational failure, distinct from drift. + +verify is read-only: it never writes files, runs git, or modifies the repo.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return Run(o, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + + cmd.Flags().StringVarP(&o.ConfigPath, "config", "c", "", "Path to config file (default: auto-detect .github/manifest.yaml)") + cmd.Flags().StringVar(&o.ManifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") + cmd.Flags().StringVar(&o.ActionFolder, "action-folder", "manage-release", "Folder name for the manage-release composite action") + cmd.Flags().StringVarP(&o.OutputPath, "output", "o", ".github/workflows/orchestrate.yaml", "Path of the orchestrate workflow") + cmd.Flags().StringVar(&o.PromoteOutputPath, "promote-output", ".github/workflows/promote.yaml", "Path of the promote workflow") + cmd.Flags().BoolVarP(&o.Quiet, "quiet", "q", false, "Suppress the per-file report body; only set the exit code") + + return cmd +} diff --git a/internal/verify/verify.go b/internal/verify/verify.go new file mode 100644 index 00000000..080c4462 --- /dev/null +++ b/internal/verify/verify.go @@ -0,0 +1,170 @@ +// Package verify implements the read-only "cascade verify" command. It compares +// the workflow and action files committed to a repository against what the +// manifest would currently generate, reporting any drift. verify never writes to +// the filesystem, invokes git, or creates scratch state: the comparison is done +// entirely in memory. +package verify + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// Exit codes returned to the process by the verify command. The contract is +// frozen: 0 means no drift, 1 means drift, 2 means the check could not run. It +// mirrors diff(1) so verify drops into a CI drift gate without surprise. +const ( + exitDrift = 1 + exitOperational = 2 +) + +// exitError pairs an error with the process exit code the verify command should +// return for it. cmd/cascade/main.go reads the code through the unexported +// ExitCode() int interface, so verify owns its exit contract without main.go +// importing this package or special-casing the command. +type exitError struct { + code int + err error +} + +// Error implements error. +func (e *exitError) Error() string { return e.err.Error() } + +// Unwrap exposes the wrapped error so errors.Is and errors.As traverse into it. +func (e *exitError) Unwrap() error { return e.err } + +// ExitCode reports the process exit code for this error. +func (e *exitError) ExitCode() int { return e.code } + +// ErrDrift is returned (wrapped) by Run when the committed files diverge from +// what the manifest would generate (a file is missing or its bytes differ). +// Callers distinguish drift from operational failures (a missing or invalid +// manifest) with errors.Is(err, ErrDrift). It carries exit code 1. +var ErrDrift = &exitError{code: exitDrift, err: errors.New("workflow drift detected")} + +// operational wraps err as a code-2 operational failure: the verify check could +// not run (the manifest is missing or invalid, or a planned file could not be +// read). The wrapped chain never contains ErrDrift, so errors.Is(err, ErrDrift) +// stays false for operational failures. +func operational(err error) error { + return &exitError{code: exitOperational, err: err} +} + +// Options configures a verify run. The fields mirror the generate-workflow flags +// that determine which files the manifest emits and where they live. +type Options struct { + ConfigPath string + ManifestKey string + ActionFolder string + OutputPath string + PromoteOutputPath string + Quiet bool +} + +// Run compares every file the manifest would generate against the bytes +// committed on disk. It returns: +// +// - nil when every planned file is present and byte-identical, mapping to +// exit code 0 (no drift), +// - ErrDrift when any planned file is missing or differs, mapping to exit +// code 1 (drift detected), or +// - an operational error when the manifest cannot be read or validated, +// mapping to exit code 2 (the check could not run). +// +// The returned error carries its exit code through an ExitCode() int method +// that cmd/cascade/main.go reads to set the process status. +// +// Run is read-only: it reads the manifest, the reusable-workflow stubs the +// generators inspect, and the committed files, and writes nothing. +func Run(o Options, stdout, stderr io.Writer) error { + planned, err := generate.Plan(generate.PlanOptions{ + ConfigPath: o.ConfigPath, + ManifestKey: o.ManifestKey, + ActionFolder: o.ActionFolder, + OutputPath: o.OutputPath, + PromoteOutputPath: o.PromoteOutputPath, + }) + if err != nil { + return operational(fmt.Errorf("planning workflows: %w", err)) + } + + // Anchor relative planned paths to the manifest's repo root so verify reads + // the committed files where the manifest lives, independent of the process + // working directory. Absolute planned paths (the composite action) are read + // as-is. The config path is resolved the same way Plan resolves it, so an + // auto-detected manifest yields the same base directory. + configPath := o.ConfigPath + if configPath == "" { + configPath = config.FindConfigFile("") + } + baseDir := generate.ResolveBaseDir(configPath) + + type drift struct { + path string + missing bool + } + var drifts []drift + + for _, p := range planned { + readPath := p.Path + if !filepath.IsAbs(readPath) { + readPath = filepath.Join(baseDir, readPath) + } + committed, rerr := os.ReadFile(readPath) + if rerr != nil { + if errors.Is(rerr, os.ErrNotExist) { + drifts = append(drifts, drift{path: p.Path, missing: true}) + continue + } + return operational(fmt.Errorf("reading %s: %w", p.Path, rerr)) + } + if !bytes.Equal(committed, []byte(p.Content)) { + drifts = append(drifts, drift{path: p.Path}) + } + } + + if len(drifts) == 0 { + if !o.Quiet { + _, _ = fmt.Fprintf(stdout, "verify: %d files, no drift\n", len(planned)) + } + return nil + } + + if !o.Quiet { + for _, d := range drifts { + if d.missing { + _, _ = fmt.Fprintf(stderr, "! %s (missing)\n", displayPath(d.path)) + } else { + _, _ = fmt.Fprintf(stderr, "~ %s\n", displayPath(d.path)) + } + } + _, _ = fmt.Fprintf(stderr, "\n%d file(s) drifted. Run `cascade generate-workflow` and commit the result.\n", len(drifts)) + } + + return ErrDrift +} + +// displayPath renders an absolute planned path relative to the current working +// directory when possible, so reports read as repo-relative paths. It falls back +// to the original path on any error. +func displayPath(path string) string { + if !filepath.IsAbs(path) { + return path + } + cwd, err := os.Getwd() + if err != nil { + return path + } + rel, err := filepath.Rel(cwd, path) + if err != nil { + return path + } + return rel +} diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go new file mode 100644 index 00000000..91cefe05 --- /dev/null +++ b/internal/verify/verify_test.go @@ -0,0 +1,243 @@ +package verify + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// newRepo lays down a representative multi-environment manifest plus the +// reusable-workflow stubs it references, then materializes the full generated +// set on disk so a clean repo verifies with no drift. It returns the repo root. +// All paths are absolute, so the tests never change the process working +// directory and stay parallel-safe. +func newRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "workflows"), 0o755)) + + stubs := map[string]string{ + ".github/workflows/image-build.yaml": "" + + "name: Image Build\non:\n workflow_call:\n inputs:\n os:\n type: string\n", + ".github/workflows/bundle-build.yaml": "" + + "name: Bundle Build\non:\n workflow_call:\n inputs:\n image:\n type: string\n", + ".github/workflows/deploy.yaml": "" + + "name: Deploy\non:\n workflow_call:\n inputs:\n environment:\n type: string\n", + } + for path, body := range stubs { + require.NoError(t, os.WriteFile(filepath.Join(dir, path), []byte(body), 0o644)) + } + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "staging", "prod"}, + Builds: []config.BuildConfig{ + {Name: "image", Workflow: ".github/workflows/image-build.yaml", Triggers: []string{"src/**"}}, + }, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}, DependsOn: []string{"image"}}, + }, + } + manifest := map[string]any{config.DefaultManifestKey: config.CICDFile{Config: cfg}} + body, err := yaml.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".github", "manifest.yaml"), body, 0o644)) + + // Materialize the full generated set so a clean repo has zero drift. The + // plan options carry absolute paths rooted at dir, so the planned files land + // in the temp repo without changing the working directory. + planned, err := generate.Plan(planOpts(dir)) + require.NoError(t, err) + for _, p := range planned { + path := p.Path + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(p.Content), 0o644)) + } + return dir +} + +// planOpts builds the generate plan options for a repo rooted at dir. Every +// path is absolute so Plan resolves the manifest, base directory, and emitted +// files without consulting the process working directory. +func planOpts(dir string) generate.PlanOptions { + return generate.PlanOptions{ + ConfigPath: filepath.Join(dir, ".github", "manifest.yaml"), + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: filepath.Join(dir, ".github", "workflows", "orchestrate.yaml"), + PromoteOutputPath: filepath.Join(dir, ".github", "workflows", "promote.yaml"), + } +} + +// opts builds the verify options for a repo rooted at dir, mirroring planOpts so +// the verify run reads the same absolute paths the plan emitted. +func opts(dir string) Options { + return Options{ + ConfigPath: filepath.Join(dir, ".github", "manifest.yaml"), + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: filepath.Join(dir, ".github", "workflows", "orchestrate.yaml"), + PromoteOutputPath: filepath.Join(dir, ".github", "workflows", "promote.yaml"), + } +} + +func TestRun_CleanRepo_NoDrift(t *testing.T) { + t.Parallel() + dir := newRepo(t) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoError(t, err) + require.Contains(t, out.String(), "no drift") +} + +func TestRun_ChangedFile_ReportsOnlyThatFile(t *testing.T) { + t.Parallel() + dir := newRepo(t) + target := filepath.Join(dir, ".github", "workflows", "orchestrate.yaml") + original, err := os.ReadFile(target) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, append(original, '\n', '#', ' ', 'x'), 0o644)) + + var out, errOut bytes.Buffer + err = Run(opts(dir), &out, &errOut) + require.Error(t, err) + require.True(t, errors.Is(err, ErrDrift), "changed file must be drift, got %v", err) + + report := errOut.String() + require.Contains(t, report, "orchestrate.yaml") + require.NotContains(t, report, "promote.yaml") +} + +func TestRun_MissingFile_ReportsMissing(t *testing.T) { + t.Parallel() + dir := newRepo(t) + target := filepath.Join(dir, ".github", "workflows", "promote.yaml") + require.NoError(t, os.Remove(target)) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.Error(t, err) + require.True(t, errors.Is(err, ErrDrift), "missing file must be drift, got %v", err) + + report := errOut.String() + require.Contains(t, report, "promote.yaml") + require.Contains(t, report, "missing") +} + +func TestRun_UnrelatedFile_Ignored(t *testing.T) { + t.Parallel() + dir := newRepo(t) + // A workflow that is NOT in the plan must be ignored entirely. + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".github", "workflows", "ci.yaml"), + []byte("name: CI\non: push\n"), 0o644)) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoError(t, err) + require.NotContains(t, errOut.String(), "ci.yaml") +} + +func TestRun_ManifestAbsent_OperationalNotDrift(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.Error(t, err) + require.False(t, errors.Is(err, ErrDrift), "absent manifest must be operational, not drift") +} + +func TestRun_Quiet_SuppressesReportBody(t *testing.T) { + t.Parallel() + dir := newRepo(t) + target := filepath.Join(dir, ".github", "workflows", "orchestrate.yaml") + original, err := os.ReadFile(target) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, append(original, '\n', '#', ' ', 'x'), 0o644)) + + o := opts(dir) + o.Quiet = true + var out, errOut bytes.Buffer + err = Run(o, &out, &errOut) + require.Error(t, err) + require.True(t, errors.Is(err, ErrDrift)) + + // No per-file report body under --quiet. + require.NotContains(t, errOut.String(), "orchestrate.yaml") + require.Empty(t, strings.TrimSpace(out.String())) +} + +func TestRun_Deterministic_CleanTwice(t *testing.T) { + t.Parallel() + dir := newRepo(t) + + for i := range 2 { + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoErrorf(t, err, "run %d expected clean", i) + } +} + +// exitCoder mirrors the interface cmd/cascade/main.go uses to map a returned +// error to a process exit code without importing this package. +type exitCoder interface{ ExitCode() int } + +func TestRun_CleanRepo_ReturnsNil_ExitZero(t *testing.T) { + t.Parallel() + dir := newRepo(t) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoError(t, err, "clean repo must return nil so the command exits 0") +} + +func TestRun_Drift_ExitCodeOne(t *testing.T) { + t.Parallel() + dir := newRepo(t) + target := filepath.Join(dir, ".github", "workflows", "orchestrate.yaml") + original, err := os.ReadFile(target) + require.NoError(t, err) + require.NoError(t, os.WriteFile(target, append(original, '\n', '#', ' ', 'x'), 0o644)) + + var out, errOut bytes.Buffer + err = Run(opts(dir), &out, &errOut) + require.True(t, errors.Is(err, ErrDrift), "drift must wrap ErrDrift, got %v", err) + + var ec exitCoder + require.ErrorAs(t, err, &ec, "drift error must carry an exit code") + require.Equal(t, 1, ec.ExitCode(), "drift maps to exit code 1") +} + +func TestRun_OperationalFailure_ExitCodeTwo(t *testing.T) { + t.Parallel() + dir := t.TempDir() // No manifest: the plan cannot run. + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.Error(t, err) + require.False(t, errors.Is(err, ErrDrift), "operational failure is not drift") + + var ec exitCoder + require.ErrorAs(t, err, &ec, "operational error must carry an exit code") + require.Equal(t, 2, ec.ExitCode(), "operational failure maps to exit code 2") +} + +func TestErrDrift_ExitCodeOne(t *testing.T) { + t.Parallel() + var ec exitCoder + require.ErrorAs(t, error(ErrDrift), &ec) + require.Equal(t, 1, ec.ExitCode()) +}