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
7 changes: 5 additions & 2 deletions docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ 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.

`verify` also reports orphans: cascade-owned workflow files left behind in the workflows output directory that the manifest no longer plans (for example, after removing an environment or build). Only files carrying the cascade-generated marker are considered, so hand-written workflows in the same directory are never flagged. An orphan is reported as drift and exits 1 like any other drift. Pass `--allow-orphans` to skip this check when stale generated files are expected. Orphan detection reads the workflows output directory only and never deletes anything.

#### Flags

| Flag | Type | Default | Description |
Expand All @@ -245,13 +247,14 @@ cascade verify
| `--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 |
| `--allow-orphans` | bool | false | Do not report cascade-owned workflow files that are no longer in the plan as drift |

#### 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 |
| 0 | No drift: every generated file is present and byte-identical, and no orphaned generated files remain |
| 1 | Drift detected: a generated file is missing, its committed bytes differ, or a cascade-owned file is orphaned (unless `--allow-orphans` is set) |
| 2 | Error: the manifest is missing or invalid, or another operational failure prevented the check from running |

#### Use in CI
Expand Down
11 changes: 10 additions & 1 deletion e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,16 @@ 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"`
// CreatePath and CreateFrom together drop a cascade-owned orphan into the
// repo before verifying: the file at CreateFrom (an existing generated file
// that already carries the generated marker) is copied to CreatePath, which
// the manifest does not plan. This drives verify's orphan-detection path.
CreatePath string `yaml:"create_path,omitempty"`
CreateFrom string `yaml:"create_from,omitempty"`
// AllowOrphans passes --allow-orphans to verify so a scenario can assert the
// opt-out suppresses orphan drift.
AllowOrphans bool `yaml:"allow_orphans,omitempty"`
ExpectExit int `yaml:"expect_exit"`
}

// StepExpect defines expected outcomes for a step
Expand Down
34 changes: 31 additions & 3 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error {
if step.Verify.MutatePath != "" && step.Verify.MutateAppend == "" {
return fmt.Errorf("step %d (%s): verify mutate_path requires mutate_append", i, step.Name)
}
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)
}
default:
return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action)
}
Expand Down Expand Up @@ -373,8 +376,11 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err
// 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.
// MutateAppend to that file before verifying, driving the drift path. When
// CreatePath/CreateFrom are set it copies an existing generated (marker-carrying)
// file to an unplanned path before verifying, driving the orphan path; AllowOrphans
// adds --allow-orphans so the opt-out can be exercised. 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)
Expand Down Expand Up @@ -418,7 +424,29 @@ func (r *Runner) executeVerify(ctx context.Context, step *VerifyStep) error {
}
}

verifyCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade verify"}
if step.CreatePath != "" {
copyCmd := []string{"bash", "-c", fmt.Sprintf(
"cd /tmp/repo && cp %s %s",
shellQuote(step.CreateFrom), shellQuote(step.CreatePath),
)}
exitCode, reader, err := r.harness.act.Container().Exec(ctx, copyCmd)
if err != nil {
return fmt.Errorf("verify: create exec failed: %w", err)
}
var out bytes.Buffer
if reader != nil {
_, _ = io.Copy(&out, reader)
}
if exitCode != 0 {
return fmt.Errorf("verify: create failed (exit %d): %s", exitCode, out.String())
}
}

verifyArgs := "/usr/local/bin/cascade verify"
if step.AllowOrphans {
verifyArgs += " --allow-orphans"
}
verifyCmd := []string{"bash", "-c", "cd /tmp/repo && " + verifyArgs}
exitCode, reader, err := r.harness.act.Container().Exec(ctx, verifyCmd)
if err != nil {
return fmt.Errorf("verify: exec failed: %w", err)
Expand Down
54 changes: 54 additions & 0 deletions e2e/scenarios/27-verify-orphan.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: "Verify Orphan Detection"
description: |
Exercises orphan detection in the read-only `cascade verify` command. After
generating workflows from a two-environment manifest, verify against pristine
output reports no drift (exit 0). Copying a generated, cascade-owned workflow
to a path the manifest does not plan leaves an orphan: verify reports drift
(exit 1). Re-running verify with --allow-orphans suppresses the orphan and
exits clean (exit 0), proving the opt-out.

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: "Orphaned generated workflow is the sole drift; verify reports it"
action: verify
verify:
regenerate: true
create_path: ".github/workflows/orchestrate-old.yaml"
create_from: ".github/workflows/orchestrate.yaml"
expect_exit: 1

- name: "Same orphan with --allow-orphans; verify is clean"
action: verify
verify:
regenerate: true
create_path: ".github/workflows/orchestrate-old.yaml"
create_from: ".github/workflows/orchestrate.yaml"
allow_orphans: true
expect_exit: 0
3 changes: 2 additions & 1 deletion internal/generate/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ func GenerateLocalActions(baseDir string, cfg *config.TrunkConfig) error {
func generateManageReleaseAction() string {
var sb strings.Builder

sb.WriteString(`# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY
sb.WriteString(GeneratedFileMarker)
sb.WriteString(`
# Regenerate with: cascade generate-workflow

name: 'Manage Release'
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func resolveOnUpdateWorkflowPath(workflow, ref string) string {
}

func (g *ExternalUpdateGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile())
}

Expand Down
2 changes: 1 addition & 1 deletion internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ func crossRepoOutputs(callbackType string) []string {
}

func (g *Generator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile())
}

Expand Down
2 changes: 1 addition & 1 deletion internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func (g *HotfixGenerator) Generate() (string, error) {
}

func (g *HotfixGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath())
sb.WriteString("#\n")
sb.WriteString("# Cascade hotfix workflow.\n")
Expand Down
12 changes: 12 additions & 0 deletions internal/generate/marker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package generate

// GeneratedFileMarker is the first line written at the top of every file the
// generators emit (workflows and the composite action). It marks a file as
// cascade-owned so tooling can distinguish generated output from hand-written
// files in the same directory. The verify command keys orphan detection off
// this exact string: a file carrying it that the manifest no longer plans is an
// orphan, while a file without it is treated as hand-written and never touched.
//
// The string is load-bearing. Changing it is a breaking change for any repo
// whose committed workflows still carry the old marker, so treat it as stable.
const GeneratedFileMarker = "# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY"
2 changes: 1 addition & 1 deletion internal/generate/merge_queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func (g *MergeQueueGenerator) Generate() (string, error) {
}

func (g *MergeQueueGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath())
sb.WriteString("#\n")
sb.WriteString("# Merge-queue validation lane (opt-in via merge_queue.enabled).\n")
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/pr_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (g *PRPreviewGenerator) Generate() (string, error) {
}

func (g *PRPreviewGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile())
}

Expand Down
2 changes: 1 addition & 1 deletion internal/generate/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ func (g *PromoteGenerator) writeMatrixBuildingLogic(sb *strings.Builder, deploy
}

func (g *PromoteGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath())
sb.WriteString("#\n")

Expand Down
2 changes: 1 addition & 1 deletion internal/generate/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (g *ReleaseGenerator) Generate() (string, error) {
}

func (g *ReleaseGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath())
sb.WriteString("#\n")

Expand Down
2 changes: 1 addition & 1 deletion internal/generate/rollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func (g *RollbackGenerator) Generate() (string, error) {
}

func (g *RollbackGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath())
sb.WriteString("#\n")
sb.WriteString("# Manual rollback: re-deploy a prior version or SHA to an environment.\n")
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/validate_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func (g *ValidateCheckGenerator) Generate() (string, error) {
}

func (g *ValidateCheckGenerator) writeHeader(sb *strings.Builder) {
sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n")
sb.WriteString(GeneratedFileMarker + "\n")
fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath())
sb.WriteString("#\n")
sb.WriteString("# Manifest-validation PR check (opt-in via validate_check.enabled).\n")
Expand Down
1 change: 1 addition & 0 deletions internal/verify/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ verify is read-only: it never writes files, runs git, or modifies the repo.`,
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")
cmd.Flags().BoolVar(&o.AllowOrphans, "allow-orphans", false, "Do not report cascade-owned workflow files that are no longer in the plan as drift")

return cmd
}
Loading
Loading