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
2 changes: 2 additions & 0 deletions cmd/cascade/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
initcmd "github.com/stablekernel/cascade/internal/initcmd"
"github.com/stablekernel/cascade/internal/log"
"github.com/stablekernel/cascade/internal/orchestrate"
"github.com/stablekernel/cascade/internal/plan"
"github.com/stablekernel/cascade/internal/promote"
"github.com/stablekernel/cascade/internal/release"
"github.com/stablekernel/cascade/internal/reset"
Expand Down Expand Up @@ -79,6 +80,7 @@ change detection, and changelog generation.`,
rootCmd.AddCommand(external.NewCommand())
rootCmd.AddCommand(generate.NewCommand())
rootCmd.AddCommand(verify.NewCommand())
rootCmd.AddCommand(plan.NewCommand())
rootCmd.AddCommand(hotfix.NewCommand())
rootCmd.AddCommand(initcmd.NewCommand())
rootCmd.AddCommand(orchestrate.NewCommand())
Expand Down
31 changes: 31 additions & 0 deletions docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,37 @@ cascade verify

Rather than wire this job by hand, set `drift_check.enabled: true` in the manifest and `generate-workflow` emits the drift-check workflow for you. See [Drift-check workflow](/configuration/#drift-check-workflow-opt-in).

### plan

Preview, as a per-file unified diff, what `generate-workflow` would change in the committed workflow and action files, without writing anything. `plan` is read-only: it never writes files, runs git, or modifies the repository.

```bash
cascade plan
```

`plan` is the human-facing preview counterpart to `verify`. For every file the manifest would generate, it prints the diff between the committed bytes and the generated bytes: a new file appears as a whole-file add, a changed file as a unified hunk, and a file already in sync produces no diff. When nothing is pending it prints a single `plan: N files, no pending changes` line; otherwise it prints the diffs followed by a summary of how many files would change.

#### 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 |

#### Exit codes

| Exit | Meaning |
|------|---------|
| 0 | Success, whether or not any diff was printed. `plan` is informational, so a pending change does not change the exit code |
| non-zero | Error: the manifest is missing or invalid, or another operational failure prevented the preview from running |

#### plan versus verify

`plan` and `verify` are separate commands with separate contracts. `plan` is the human preview you read before regenerating: it shows the actual diff and always exits 0 on success, so it never fails a build on its own. `verify` is the pass/fail gate you wire into CI: it prints a terse drift report and exits 1 on drift, 2 on an operational failure, so it fails the build when committed workflows fall out of sync. Reach for `plan` at the terminal to see what would change, and for `verify` in a CI job to enforce that nothing has.

### branch-protection

Emit the JSON body an operator applies to GitHub's branch-protection API for a cascade-managed trunk. cascade emits the file; the operator applies it. cascade never calls the GitHub API.
Expand Down
21 changes: 21 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ type Step struct {
// asserts the committed workflows match the manifest, exercising verify's
// exit-code contract.
Verify *VerifyStep `yaml:"verify,omitempty"`
// Plan configures a "plan" action: a read-only `cascade plan` run that prints
// a per-file unified diff of committed-vs-planned workflows and always exits 0
// on success, exercising plan's informational (non-gate) contract.
Plan *PlanStep `yaml:"plan,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 @@ -221,6 +225,23 @@ type VerifyStep struct {
ExpectExit int `yaml:"expect_exit"`
}

// PlanStep defines a plan action: a read-only `cascade plan` run that prints a
// per-file unified diff of committed-vs-planned workflows and always exits 0 on
// success. Regenerate runs `cascade generate-workflow -f` first so plan previews
// against pristine generated output. MutatePath/MutateAppend optionally append to
// a generated file before planning so a scenario can drive a specific diff.
// ExpectExit is the exit code `cascade plan` must return (0 on success).
// ExpectContains, when set, are substrings the plan stdout must contain.
// ExpectNotContains, when set, are substrings the stdout must NOT contain.
type PlanStep struct {
Regenerate bool `yaml:"regenerate,omitempty"`
MutatePath string `yaml:"mutate_path,omitempty"`
MutateAppend string `yaml:"mutate_append,omitempty"`
ExpectExit int `yaml:"expect_exit"`
ExpectContains []string `yaml:"expect_contains,omitempty"`
ExpectNotContains []string `yaml:"expect_not_contains,omitempty"`
}

// StepExpect defines expected outcomes for a step
type StepExpect struct {
State map[string]*StateExpect `yaml:"state,omitempty"`
Expand Down
88 changes: 88 additions & 0 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error {
if (step.Verify.CreatePath == "") != (step.Verify.CreateFrom == "") {
return fmt.Errorf("step %d (%s): verify create_path and create_from must be set together", i, step.Name)
}
case "plan":
if step.Plan == nil {
return fmt.Errorf("step %d (%s): plan action requires plan config", i, step.Name)
}
if step.Plan.MutatePath != "" && step.Plan.MutateAppend == "" {
return fmt.Errorf("step %d (%s): plan 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 @@ -367,6 +374,8 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err
return r.executeRollback(ctx, step.Rollback, config)
case "verify":
return r.executeVerify(ctx, step.Verify)
case "plan":
return r.executePlan(ctx, step.Plan)
default:
return fmt.Errorf("unknown action: %s", step.Action)
}
Expand Down Expand Up @@ -463,6 +472,85 @@ func (r *Runner) executeVerify(ctx context.Context, step *VerifyStep) error {
return nil
}

// executePlan runs `cascade plan` in the synced repo and asserts the exit code
// matches the step's ExpectExit (0 on success, since plan is informational and
// never a gate). When Regenerate is set it first runs `cascade generate-workflow
// -f` so plan previews against pristine generated output rather than the
// harness's localized copies. When MutatePath is set it appends MutateAppend to
// that file before planning, driving a specific diff. The captured stdout is
// checked against ExpectContains/ExpectNotContains. The whole step is
// read-through-the-CLI and never asserts on workflow execution.
func (r *Runner) executePlan(ctx context.Context, step *PlanStep) error {
if r.harness == nil || r.harness.act == nil {
r.t.Logf(" Would run cascade plan (expect exit %d, no harness)", step.ExpectExit)
return nil
}

if err := r.harness.SyncRepoToActContainer(ctx); err != nil {
return fmt.Errorf("plan: 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("plan: regenerate exec failed: %w", err)
}
var out bytes.Buffer
if reader != nil {
_, _ = io.Copy(&out, reader)
}
if exitCode != 0 {
return fmt.Errorf("plan: 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("plan: mutate exec failed: %w", err)
}
var out bytes.Buffer
if reader != nil {
_, _ = io.Copy(&out, reader)
}
if exitCode != 0 {
return fmt.Errorf("plan: mutate failed (exit %d): %s", exitCode, out.String())
}
}

planCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade plan"}
exitCode, reader, err := r.harness.act.Container().Exec(ctx, planCmd)
if err != nil {
return fmt.Errorf("plan: exec failed: %w", err)
}
var out bytes.Buffer
if reader != nil {
_, _ = io.Copy(&out, reader)
}
output := out.String()
r.t.Logf(" Plan: exit=%d (expected %d): %s", exitCode, step.ExpectExit, output)

if exitCode != step.ExpectExit {
return fmt.Errorf("plan: expected exit %d, got %d: %s", step.ExpectExit, exitCode, output)
}
for _, want := range step.ExpectContains {
if !strings.Contains(output, want) {
return fmt.Errorf("plan: stdout missing expected substring %q: %s", want, output)
}
}
for _, unwant := range step.ExpectNotContains {
if strings.Contains(output, unwant) {
return fmt.Errorf("plan: stdout contains unexpected substring %q: %s", unwant, output)
}
}
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 {
Expand Down
50 changes: 50 additions & 0 deletions e2e/scenarios/33-plan-diff.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: "Plan Diff Preview"
description: |
Exercises the read-only `cascade plan` command end to end. After generating
workflows from a two-environment manifest, plan against pristine generated
output reports no pending changes and exits 0. Appending to a committed
workflow makes plan render a per-file unified diff with the new line and a
trailing summary, and it still exits 0 because plan is informational rather
than a gate.

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: "Plan clean against pristine generated output"
action: plan
plan:
regenerate: true
expect_exit: 0
expect_contains:
- "no pending changes"

- name: "Append to a generated workflow; plan shows a unified diff"
action: plan
plan:
mutate_path: ".github/workflows/orchestrate.yaml"
mutate_append: "\n# pending change\n"
expect_exit: 0
expect_contains:
- "a/.github/workflows/orchestrate.yaml"
- "# pending change"
- "would change"
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/stablekernel/cascade
go 1.25

require (
github.com/pmezard/go-difflib v1.0.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
Expand All @@ -12,7 +13,6 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/text v0.14.0 // indirect
)
46 changes: 46 additions & 0 deletions internal/plan/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package plan

import (
"github.com/spf13/cobra"

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

// NewCommand creates the plan command, a read-only preview that renders, as a
// per-file unified diff, what generate-workflow would change in the committed
// workflow and action files.
func NewCommand() *cobra.Command {
var o Options

cmd := &cobra.Command{
Use: "plan",
Short: "Preview the workflow diff the manifest would generate",
Long: `Preview, as a per-file unified diff, what generate-workflow would change in the
committed GitHub Actions workflow and action files, without writing anything.

plan is the human-facing preview counterpart to verify. It prints the diff
between the committed bytes and what the manifest would currently generate: a
new file appears as a whole-file add, a changed file as a unified hunk, and a
file already in sync produces no diff.

plan always exits 0 on success whether or not a diff exists, because it is
informational rather than a gate. It exits non-zero only for an operational
failure, such as a missing or invalid manifest. This is the key difference from
verify: plan is the human preview you read, while verify is the pass/fail gate
you wire into CI.

plan is read-only: it never writes files, runs git, or modifies the repository.`,
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")

return cmd
}
Loading
Loading