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
10 changes: 10 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"pr_preview": { "$ref": "#/definitions/prPreviewConfig" },
"validate_check": { "$ref": "#/definitions/validateCheckConfig" },
"merge_queue": { "$ref": "#/definitions/mergeQueueConfig" },
"drift_check": { "$ref": "#/definitions/driftCheckConfig" },
"pin_mode": {
"type": "string",
"enum": ["tag", "sha"],
Expand Down Expand Up @@ -634,6 +635,15 @@
"enabled": { "type": "boolean" }
}
},
"driftCheckConfig": {
"type": "object",
"additionalProperties": false,
"description": "Workflow drift-check PR lane. When enabled, cascade emits a pull_request workflow that runs cascade verify and fails on drift. When comment is also set, cascade emits the fork-safe workflow_run companion that posts the result as a sticky PR comment.",
"properties": {
"enabled": { "type": "boolean" },
"comment": { "type": "boolean" }
}
},
"telemetryConfig": {
"type": "object",
"additionalProperties": false,
Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Check that the committed workflow and action files match what the manifest would
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` 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, drift-check, drift-comment, 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.

Expand Down Expand Up @@ -265,6 +265,8 @@ cascade verify
- run: 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).

### manage-release

Manage GitHub releases.
Expand Down
25 changes: 25 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,31 @@ ci:

Omit this section to use the built-in conventional commit parser.

### Drift-check workflow (opt-in)

Set `drift_check.enabled: true` and `generate-workflow` emits a pull-request workflow that runs [`cascade verify`](/cli-reference/#verify) and fails the check whenever the committed workflows fall out of sync with the manifest. This wires the same protection cascade uses on its own repository into yours, without hand-rolling the job.

```yaml
ci:
config:
drift_check:
enabled: true
comment: true
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | false | Emit the pull-request drift-check workflow (`.github/workflows/cascade-drift-check.yaml`) |
| `comment` | bool | false | Also emit the fork-safe comment companion (`.github/workflows/cascade-drift-comment.yaml`) |

Behavior:

- **Opt-in and additive.** Omit `drift_check` and nothing is emitted; existing output is byte-for-byte identical to before.
- **Read-only on the pull request.** The `cascade-drift-check.yaml` job triggers on `pull_request` with `contents: read` only. A pull request from a fork gets a read-only token and no secrets, so the job cannot comment or write. It captures the verify result as a `cascade-drift-result` artifact instead, and re-exits non-zero on drift to keep the check red.
- **Fork-safe comment companion.** When `comment: true`, `cascade-drift-comment.yaml` triggers on `workflow_run` in the base-repo context, where it has a scoped `pull-requests: write` token. It downloads the artifact (data only), then posts or updates a sticky comment with the verify output. It never checks out or executes pull-request head code.
- **Trusted PR resolution.** The companion derives the target pull-request number only from trusted `workflow_run` run metadata (the source run's `pull_requests` array, or a head-SHA lookup for fork pull requests), never from the artifact the pull-request job uploads. A fork therefore cannot redirect the comment at another pull request.
- **cascade-owned.** Both files carry the cascade-generated marker, so `cascade verify` itself tracks them: edit them by hand and they are reported as drift; remove the toggle and they are reported as orphans.

## State Section

The `state` section tracks deployment state per environment plus a synthetic `release` slot. The framework manages it automatically. Do not hand-edit.
Expand Down
4 changes: 4 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ type Config struct {
ValidateCheck map[string]any `yaml:"validate_check,omitempty"`
MergeQueue map[string]any `yaml:"merge_queue,omitempty"`
PRPreview map[string]any `yaml:"pr_preview,omitempty"`
// DriftCheck carries the opt-in drift-check lane (enabled, comment) through to
// the generated manifest untouched, so a scenario can enable the generated PR
// drift-check workflow and its fork-safe comment companion (#229).
DriftCheck map[string]any `yaml:"drift_check,omitempty"`
Notify map[string]any `yaml:"notify,omitempty"`
External []map[string]any `yaml:"external,omitempty"`
// Telemetry carries the reserved vendor-neutral telemetry block (enabled,
Expand Down
61 changes: 61 additions & 0 deletions e2e/scenarios/28-drift-check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: "Generate Drift-Check Workflow"
description: |
Exercises the opt-in drift_check lane (#229) end to end. A manifest that
enables drift_check (with the comment companion) generates two cascade-owned
workflows: the pull_request drift-check and the fork-safe workflow_run comment
companion. After regenerating, verify against pristine output is clean
(exit 0), proving the generated files round-trip through verify with no drift
or orphan. Copying the generated cascade-owned drift-check workflow to a path
the manifest does not plan makes verify report exactly one orphan (exit 1),
which proves the file was produced AND carries the cascade marker (verify only
flags marker-bearing files as orphans). Re-running with --allow-orphans
suppresses it and exits clean (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/**"]
drift_check:
enabled: true
comment: true

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: "Copied cascade-owned drift-check workflow is the sole orphan"
action: verify
verify:
regenerate: true
create_path: ".github/workflows/cascade-drift-check-old.yaml"
create_from: ".github/workflows/cascade-drift-check.yaml"
expect_exit: 1

- name: "Same orphan with --allow-orphans; verify is clean"
action: verify
verify:
regenerate: true
create_path: ".github/workflows/cascade-drift-check-old.yaml"
create_from: ".github/workflows/cascade-drift-check.yaml"
allow_orphans: true
expect_exit: 0
30 changes: 30 additions & 0 deletions internal/config/drift_check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package config

import "testing"

// TestParseDriftCheck proves the opt-in drift_check lane parses both toggles.
func TestParseDriftCheck(t *testing.T) {
cfg := parseInline(t, `
trunk_branch: main
drift_check:
enabled: true
comment: true
`)
if cfg.DriftCheck == nil || !cfg.DriftCheck.Enabled || !cfg.DriftCheck.Comment {
t.Fatalf("drift_check: %#v", cfg.DriftCheck)
}
}

// TestDriftCheckValidatesAtCurrentSchemaVersion proves the drift_check toggle is
// additive: a manifest that sets it validates cleanly at CurrentSchemaVersion,
// confirming the schema version was not bumped to introduce the field.
func TestDriftCheckValidatesAtCurrentSchemaVersion(t *testing.T) {
cfg := &TrunkConfig{
SchemaVersion: CurrentSchemaVersion,
TrunkBranch: "main",
DriftCheck: &DriftCheckConfig{Enabled: true, Comment: true},
}
for _, e := range Validate(cfg) {
t.Fatalf("unexpected validation error for drift_check at current schema version: %s", e)
}
}
13 changes: 13 additions & 0 deletions internal/config/schema_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,19 @@ type MergeQueueConfig struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
}

// DriftCheckConfig is the opt-in workflow drift-check lane (#229). When Enabled,
// cascade emits a pull_request workflow that runs cascade verify and fails the
// check on drift. When Comment is also set, cascade emits the fork-safe
// workflow_run companion that posts the verify result as a sticky PR comment.
//
// The companion derives the target PR number ONLY from trusted workflow_run run
// metadata, never from the artifact the pull_request job uploads, so a fork PR
// cannot redirect the comment at another PR.
type DriftCheckConfig struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Comment bool `yaml:"comment,omitempty" json:"comment,omitempty"`
}

// Pin mode constants.
const (
PinModeTag = "tag"
Expand Down
1 change: 1 addition & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ type TrunkConfig struct {
PRPreview *PRPreviewConfig `yaml:"pr_preview,omitempty" json:"pr_preview,omitempty"`
ValidateCheck *ValidateCheckConfig `yaml:"validate_check,omitempty" json:"validate_check,omitempty"`
MergeQueue *MergeQueueConfig `yaml:"merge_queue,omitempty" json:"merge_queue,omitempty"`
DriftCheck *DriftCheckConfig `yaml:"drift_check,omitempty" json:"drift_check,omitempty"` // Opt-in workflow drift-check PR lane (#229)
PinMode string `yaml:"pin_mode,omitempty" json:"pin_mode,omitempty"` // tag | sha (default tag)
ActionPins map[string]string `yaml:"action_pins,omitempty" json:"action_pins,omitempty"`
Telemetry *TelemetryConfig `yaml:"telemetry,omitempty" json:"telemetry,omitempty"`
Expand Down
40 changes: 40 additions & 0 deletions internal/generate/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,46 @@ func runGenerateWorkflow(opts generateOptions) error {
}
}

// Generate the opt-in workflow drift-check lane (#229). Absent or disabled
// drift_check emits nothing, so existing manifests are unaffected. The
// comment companion is emitted only when drift_check.comment is also set.
driftGen := NewDriftCheckGenerator(cfg, baseDir)
if driftGen.Enabled() {
content, err := driftGen.Generate()
if err != nil {
return fmt.Errorf("generating drift-check workflow: %w", err)
}
outPath := ".github/workflows/cascade-drift-check.yaml"
if opts.dryRun {
fmt.Println("\n=== cascade-drift-check.yaml ===")
fmt.Print(content)
} else {
if err := writeWorkflow(outPath, content, opts.force); err != nil {
return err
}
generatedFiles = append(generatedFiles, outPath)
fmt.Printf("Generated workflow: %s\n", outPath)
}

if driftGen.commentEnabled() {
commentContent, err := driftGen.GenerateComment()
if err != nil {
return fmt.Errorf("generating drift-comment workflow: %w", err)
}
commentOutPath := ".github/workflows/cascade-drift-comment.yaml"
if opts.dryRun {
fmt.Println("\n=== cascade-drift-comment.yaml ===")
fmt.Print(commentContent)
} else {
if err := writeWorkflow(commentOutPath, commentContent, opts.force); err != nil {
return err
}
generatedFiles = append(generatedFiles, commentOutPath)
fmt.Printf("Generated workflow: %s\n", commentOutPath)
}
}
}

if opts.dryRun {
return nil
}
Expand Down
Loading
Loading