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
17 changes: 16 additions & 1 deletion cmd/cascade/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"errors"
"fmt"
"os"

Expand All @@ -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"
)

Expand Down Expand Up @@ -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())
Expand All @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
81 changes: 81 additions & 0 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions e2e/scenarios/22-verify-drift.yaml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 19 additions & 9 deletions internal/generate/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<folder>/action.yaml where <folder> 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)
}

Expand Down
Loading
Loading