diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json
index b0b4b7b..11b3e22 100644
--- a/docs/public/manifest.schema.json
+++ b/docs/public/manifest.schema.json
@@ -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"],
@@ -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,
diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md
index 6fa2e56..2ee8a17 100644
--- a/docs/src/content/docs/cli-reference.md
+++ b/docs/src/content/docs/cli-reference.md
@@ -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.
@@ -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.
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index 1b59760..940cd3b 100644
--- a/docs/src/content/docs/configuration.md
+++ b/docs/src/content/docs/configuration.md
@@ -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.
diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go
index c11addb..9c1747a 100644
--- a/e2e/harness/scenario.go
+++ b/e2e/harness/scenario.go
@@ -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,
diff --git a/e2e/scenarios/28-drift-check.yaml b/e2e/scenarios/28-drift-check.yaml
new file mode 100644
index 0000000..f4e2bd5
--- /dev/null
+++ b/e2e/scenarios/28-drift-check.yaml
@@ -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
diff --git a/internal/config/drift_check_test.go b/internal/config/drift_check_test.go
new file mode 100644
index 0000000..50cc34c
--- /dev/null
+++ b/internal/config/drift_check_test.go
@@ -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)
+ }
+}
diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go
index c41252a..6431b3f 100644
--- a/internal/config/schema_v1.go
+++ b/internal/config/schema_v1.go
@@ -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"
diff --git a/internal/config/types.go b/internal/config/types.go
index 05bfe80..7ac7a79 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -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"`
diff --git a/internal/generate/command.go b/internal/generate/command.go
index 93d4695..e7d39c0 100644
--- a/internal/generate/command.go
+++ b/internal/generate/command.go
@@ -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
}
diff --git a/internal/generate/drift_check.go b/internal/generate/drift_check.go
new file mode 100644
index 0000000..e5f6cf5
--- /dev/null
+++ b/internal/generate/drift_check.go
@@ -0,0 +1,335 @@
+package generate
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/stablekernel/cascade/internal/config"
+)
+
+// driftCheckMarker is the hidden HTML marker embedded in the drift comment so a
+// later run finds and updates the same sticky comment instead of posting a new
+// one. It matches the marker cascade uses for its own dogfood drift comment.
+const driftCheckMarker = ""
+
+// driftCheckArtifact is the name of the artifact the pull_request job uploads
+// and the workflow_run companion downloads. It carries the verify report and the
+// captured exit code as data only; it never decides which PR is commented on.
+const driftCheckArtifact = "cascade-drift-result"
+
+// driftCheckWorkflowName is the workflow name the pull_request lane runs under.
+// The companion subscribes to completed runs of this exact name, so the two
+// generated files must agree on it.
+const driftCheckWorkflowName = "Cascade Drift Check"
+
+// driftCommentWorkflowName is the workflow name the fork-safe comment companion
+// runs under.
+const driftCommentWorkflowName = "Cascade Drift Comment"
+
+// DriftCheckGenerator emits the opt-in workflow drift-check lane (#229).
+//
+// It mirrors cascade's own dogfood: a pull_request job builds nothing
+// dangerous, runs cascade verify against the manifest, captures the result as an
+// artifact, and re-exits on drift to keep the PR gate red. The pull_request job
+// is strictly read-only (contents: read, no secrets, posts no comment) so a fork
+// PR cannot abuse it.
+//
+// When Comment is enabled it also emits a fork-safe workflow_run companion that
+// runs in the base-repo context with a scoped write token, downloads the
+// artifact (data only), and posts a sticky comment. The companion derives the
+// target PR number ONLY from trusted workflow_run run metadata (the source run's
+// pull_requests array, or a head-SHA lookup for fork PRs), never from the
+// fork-controlled artifact, so a fork cannot redirect the comment at another PR.
+type DriftCheckGenerator struct {
+ config *config.TrunkConfig
+ baseDir string
+}
+
+// NewDriftCheckGenerator creates a new drift-check workflow generator.
+func NewDriftCheckGenerator(cfg *config.TrunkConfig, baseDir string) *DriftCheckGenerator {
+ return &DriftCheckGenerator{config: cfg, baseDir: baseDir}
+}
+
+// Enabled reports whether the drift-check lane should be emitted.
+func (g *DriftCheckGenerator) Enabled() bool {
+ return g.config.DriftCheck != nil && g.config.DriftCheck.Enabled
+}
+
+// commentEnabled reports whether the fork-safe comment companion should also be
+// emitted alongside the pull_request check.
+func (g *DriftCheckGenerator) commentEnabled() bool {
+ return g.config.DriftCheck != nil && g.config.DriftCheck.Comment
+}
+
+// getCLIRef returns the Git ref for the cascade self-action. The default
+// (cli_version unset or "latest") resolves to an immutable release tag, so
+// consumers never run an unpinned mutable ref; "beta" opts in to "master".
+func (g *DriftCheckGenerator) getCLIRef() string {
+ if g.config.CLIVersion == "beta" {
+ return "master"
+ }
+ return g.config.GetCLIVersion()
+}
+
+// Generate creates the pull_request drift-check workflow content.
+func (g *DriftCheckGenerator) Generate() (string, error) {
+ if !g.Enabled() {
+ return "", fmt.Errorf("cannot generate drift-check workflow: drift_check is not enabled")
+ }
+
+ var sb strings.Builder
+ g.writeHeader(&sb)
+ g.writeCheckTrigger(&sb)
+ g.writeCheckJob(&sb)
+ return sb.String(), nil
+}
+
+// GenerateComment creates the fork-safe workflow_run comment companion content.
+// Callers must check commentEnabled (via the command/plan wiring) before
+// emitting this file.
+func (g *DriftCheckGenerator) GenerateComment() (string, error) {
+ if !g.Enabled() || !g.commentEnabled() {
+ return "", fmt.Errorf("cannot generate drift-comment workflow: drift_check.comment is not enabled")
+ }
+
+ var sb strings.Builder
+ g.writeHeader(&sb)
+ g.writeCommentTrigger(&sb)
+ g.writeCommentJob(&sb)
+ return sb.String(), nil
+}
+
+func (g *DriftCheckGenerator) writeHeader(sb *strings.Builder) {
+ sb.WriteString(GeneratedFileMarker + "\n")
+ fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile())
+}
+
+// writeCheckTrigger emits the pull_request trigger and the read-only top-level
+// permissions. A fork PR gets a read-only token and no secrets, so this job
+// cannot comment or write; it captures the drift result as an artifact instead.
+func (g *DriftCheckGenerator) writeCheckTrigger(sb *strings.Builder) {
+ fmt.Fprintf(sb, "name: %s\n\n", driftCheckWorkflowName)
+ sb.WriteString("on:\n")
+ sb.WriteString(" pull_request:\n")
+ sb.WriteString("\npermissions:\n")
+ sb.WriteString(" contents: read\n")
+ sb.WriteString("\nconcurrency:\n")
+ sb.WriteString(" group: \"cascade-drift-check-${{ github.event.pull_request.number }}\"\n")
+ sb.WriteString(" cancel-in-progress: true\n")
+}
+
+func (g *DriftCheckGenerator) writeCheckJob(sb *strings.Builder) {
+ sb.WriteString("\njobs:\n")
+ sb.WriteString(" drift-check:\n")
+ sb.WriteString(" name: Workflow Drift Check\n")
+ sb.WriteString(" runs-on: ubuntu-latest\n")
+ // Re-state read-only permissions at job scope so the job is read-only
+ // regardless of any future change to the workflow default.
+ sb.WriteString(" permissions:\n")
+ sb.WriteString(" contents: read\n")
+ sb.WriteString(" steps:\n")
+
+ writeActionStep(sb, g.config, " ", actionCheckout)
+ sb.WriteString("\n")
+
+ sb.WriteString(" - name: Setup CLI\n")
+ fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef())
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(sb, " version: %s\n", g.config.GetCLIVersion())
+ sb.WriteString("\n")
+
+ // Run verify, capturing stdout/stderr and the exit code without failing the
+ // step, so the result is always uploaded for the companion to comment on.
+ sb.WriteString(" - name: Check for workflow drift\n")
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" set +e\n")
+ fmt.Fprintf(sb, " cascade verify --config %s > drift-report.txt 2>&1\n", g.config.GetManifestFile())
+ sb.WriteString(" echo $? > drift-exit.txt\n")
+ sb.WriteString(" set -e\n")
+ sb.WriteString(" cat drift-report.txt\n")
+ sb.WriteString("\n")
+
+ if g.commentEnabled() {
+ sb.WriteString(" - name: Upload drift result\n")
+ sb.WriteString(" if: always()\n")
+ writeActionUses(sb, g.config, " ", actionUploadArtifact)
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(sb, " name: %s\n", driftCheckArtifact)
+ sb.WriteString(" path: |\n")
+ sb.WriteString(" drift-report.txt\n")
+ sb.WriteString(" drift-exit.txt\n")
+ sb.WriteString(" retention-days: 1\n")
+ sb.WriteString("\n")
+ }
+
+ sb.WriteString(" - name: Fail on drift\n")
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" CODE=$(cat drift-exit.txt)\n")
+ sb.WriteString(" if [ \"$CODE\" != \"0\" ]; then\n")
+ sb.WriteString(" echo \"Workflows are out of sync with the manifest.\"\n")
+ fmt.Fprintf(sb, " echo \"Run: cascade generate-workflow --config %s --force\"\n", g.config.GetManifestFile())
+ sb.WriteString(" echo \"Then commit the regenerated workflows.\"\n")
+ sb.WriteString(" fi\n")
+ sb.WriteString(" exit \"$CODE\"\n")
+}
+
+// writeCommentTrigger emits the workflow_run trigger and the locked-down
+// top-level permissions for the comment companion.
+func (g *DriftCheckGenerator) writeCommentTrigger(sb *strings.Builder) {
+ fmt.Fprintf(sb, "name: %s\n\n", driftCommentWorkflowName)
+ sb.WriteString("on:\n")
+ sb.WriteString(" workflow_run:\n")
+ fmt.Fprintf(sb, " workflows: [%q]\n", driftCheckWorkflowName)
+ sb.WriteString(" types: [completed]\n")
+ // Default to no permissions; the single job opts into the minimum it needs.
+ sb.WriteString("\npermissions: {}\n")
+ // Serialize companion runs per source run so two rapid pushes cannot race
+ // two comment jobs and double-post before the sticky-marker lookup settles.
+ sb.WriteString("\nconcurrency:\n")
+ sb.WriteString(" group: \"cascade-drift-comment-${{ github.event.workflow_run.id }}\"\n")
+ sb.WriteString(" cancel-in-progress: false\n")
+}
+
+func (g *DriftCheckGenerator) writeCommentJob(sb *strings.Builder) {
+ sb.WriteString("\njobs:\n")
+ sb.WriteString(" comment:\n")
+ sb.WriteString(" name: Comment on drift\n")
+ sb.WriteString(" runs-on: ubuntu-latest\n")
+ // Only act on PR-triggered source runs.
+ sb.WriteString(" if: github.event.workflow_run.event == 'pull_request'\n")
+ sb.WriteString(" permissions:\n")
+ sb.WriteString(" pull-requests: write\n")
+ sb.WriteString(" actions: read\n")
+ sb.WriteString(" steps:\n")
+
+ sb.WriteString(" - name: Download drift result\n")
+ sb.WriteString(" id: download\n")
+ sb.WriteString(" continue-on-error: true\n")
+ writeActionUses(sb, g.config, " ", actionDownloadArtifact)
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(sb, " name: %s\n", driftCheckArtifact)
+ fmt.Fprintf(sb, " path: %s\n", driftCheckArtifact)
+ sb.WriteString(" run-id: ${{ github.event.workflow_run.id }}\n")
+ sb.WriteString(" github-token: ${{ github.token }}\n")
+ sb.WriteString("\n")
+
+ sb.WriteString(" - name: Post or update sticky comment\n")
+ sb.WriteString(" if: steps.download.outcome == 'success'\n")
+ writeActionUses(sb, g.config, " ", actionGithubScript)
+ sb.WriteString(" with:\n")
+ sb.WriteString(" script: |\n")
+ g.writeCommentScript(sb)
+}
+
+// writeCommentScript emits the github-script body. It derives the target PR
+// number ONLY from trusted workflow_run run metadata, never from the
+// fork-controlled artifact, and never checks out or executes PR head code.
+func (g *DriftCheckGenerator) writeCommentScript(sb *strings.Builder) {
+ lines := []string{
+ "const fs = require('fs');",
+ fmt.Sprintf("const marker = '%s';", driftCheckMarker),
+ "",
+ "// Read artifact files (data only; never executed).",
+ "const read = (name) => {",
+ " try {",
+ fmt.Sprintf(" return fs.readFileSync(`%s/${name}`, 'utf8');", driftCheckArtifact),
+ " } catch (e) {",
+ " return '';",
+ " }",
+ "};",
+ "",
+ "// Resolve the target PR ONLY from trusted workflow_run metadata.",
+ "// The artifact is produced by the (possibly fork) source run and is",
+ "// attacker-controlled, so it must never decide which PR we touch.",
+ "const run = context.payload.workflow_run;",
+ "let prNumber;",
+ "if (run.pull_requests && run.pull_requests.length > 0) {",
+ " // Same-repo PRs populate this array directly.",
+ " prNumber = run.pull_requests[0].number;",
+ "} else {",
+ " // Fork PRs leave it empty; resolve via the head SHA instead.",
+ " const associated = await github.rest.repos.listPullRequestsAssociatedWithCommit({",
+ " owner: context.repo.owner,",
+ " repo: context.repo.repo,",
+ " commit_sha: run.head_sha,",
+ " });",
+ " const match = associated.data.find((pr) => pr.head.sha === run.head_sha);",
+ " if (match) {",
+ " prNumber = match.number;",
+ " }",
+ "}",
+ "if (!Number.isInteger(prNumber) || prNumber <= 0) {",
+ " core.info('No PR resolved from workflow_run metadata; nothing to do.');",
+ " return;",
+ "}",
+ "",
+ "const exitRaw = read('drift-exit.txt').trim();",
+ "const drift = exitRaw !== '0';",
+ "const report = read('drift-report.txt');",
+ "",
+ "// Find an existing sticky comment by the hidden marker.",
+ "const comments = await github.paginate(",
+ " github.rest.issues.listComments,",
+ " { owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber }",
+ ");",
+ "const existing = comments.find((c) => c.body && c.body.includes(marker));",
+ "",
+ "// Build the body in JS from the file contents. The report is plain",
+ "// text from cascade verify; it is fenced, never evaluated.",
+ "let body;",
+ "if (drift) {",
+ " const trimmed = report.length > 60000",
+ " ? report.slice(0, 60000) + '\\n... (truncated)'",
+ " : report;",
+ " body = [",
+ " marker,",
+ " '## Workflow drift detected',",
+ " '',",
+ " 'The generated workflows are out of sync with the manifest.',",
+ " '',",
+ " 'To fix, run and commit the result:',",
+ " '',",
+ " '```',",
+ fmt.Sprintf(" 'cascade generate-workflow --config %s --force',", g.config.GetManifestFile()),
+ " '```',",
+ " '',",
+ " 'cascade verify output
',",
+ " '',",
+ " '```',",
+ " trimmed,",
+ " '```',",
+ " '',",
+ " ' ',",
+ " ].join('\\n');",
+ "} else {",
+ " if (!existing) {",
+ " core.info('No drift and no existing comment; nothing to do.');",
+ " return;",
+ " }",
+ " body = [marker, 'No workflow drift detected.'].join('\\n');",
+ "}",
+ "",
+ "if (existing) {",
+ " await github.rest.issues.updateComment({",
+ " owner: context.repo.owner,",
+ " repo: context.repo.repo,",
+ " comment_id: existing.id,",
+ " body,",
+ " });",
+ "} else {",
+ " await github.rest.issues.createComment({",
+ " owner: context.repo.owner,",
+ " repo: context.repo.repo,",
+ " issue_number: prNumber,",
+ " body,",
+ " });",
+ "}",
+ }
+ for _, l := range lines {
+ if l == "" {
+ sb.WriteString("\n")
+ continue
+ }
+ fmt.Fprintf(sb, " %s\n", l)
+ }
+}
diff --git a/internal/generate/drift_check_test.go b/internal/generate/drift_check_test.go
new file mode 100644
index 0000000..7cb9879
--- /dev/null
+++ b/internal/generate/drift_check_test.go
@@ -0,0 +1,181 @@
+package generate
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/stablekernel/cascade/internal/config"
+)
+
+func driftCheckConfig(comment bool) *config.TrunkConfig {
+ return &config.TrunkConfig{
+ TrunkBranch: "main",
+ Environments: []string{"dev", "prod"},
+ DriftCheck: &config.DriftCheckConfig{Enabled: true, Comment: comment},
+ }
+}
+
+func TestDriftCheckGenerator_Enabled(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *config.TrunkConfig
+ want bool
+ }{
+ {"nil drift_check", &config.TrunkConfig{}, false},
+ {"present but disabled", &config.TrunkConfig{DriftCheck: &config.DriftCheckConfig{}}, false},
+ {"enabled", &config.TrunkConfig{DriftCheck: &config.DriftCheckConfig{Enabled: true}}, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, NewDriftCheckGenerator(tt.cfg, t.TempDir()).Enabled())
+ })
+ }
+}
+
+func TestDriftCheckGenerator_Generate_Disabled(t *testing.T) {
+ _, err := NewDriftCheckGenerator(&config.TrunkConfig{}, t.TempDir()).Generate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "drift_check is not enabled")
+}
+
+// TestDriftCheckGenerator_CheckJob_ReadOnly proves the pull_request lane is
+// strictly read-only: it carries the cascade-owned marker, runs cascade verify,
+// re-exits on drift, and never grants write permissions, references secrets, or
+// posts a comment itself.
+func TestDriftCheckGenerator_CheckJob_ReadOnly(t *testing.T) {
+ content, err := NewDriftCheckGenerator(driftCheckConfig(true), t.TempDir()).Generate()
+ require.NoError(t, err)
+
+ assert.True(t, strings.HasPrefix(content, GeneratedFileMarker),
+ "generated file must start with the cascade-owned marker")
+ assert.Contains(t, content, "on:\n pull_request:")
+ assert.Contains(t, content, "permissions:\n contents: read")
+ assert.Contains(t, content, "cascade verify --config .github/manifest.yaml")
+ assert.Contains(t, content, `exit "$CODE"`, "must re-exit on drift to keep the gate red")
+
+ // Security invariant: the pull_request lane must NOT carry write scope, must
+ // NOT reference secrets, and must NOT post a comment itself.
+ assert.NotContains(t, content, "pull-requests: write",
+ "pull_request job must never have write permissions")
+ assert.NotContains(t, content, "secrets.", "pull_request job must not reference secrets")
+ assert.NotContains(t, content, "createComment", "pull_request job must not post a comment")
+ assert.NotContains(t, content, "github-script", "pull_request job must not run github-script")
+}
+
+// TestDriftCheckGenerator_NoComment_OmitsArtifact proves that when the comment
+// companion is disabled the check lane does not upload the artifact (there is no
+// consumer), keeping the OFF-comment output minimal.
+func TestDriftCheckGenerator_NoComment_OmitsArtifact(t *testing.T) {
+ content, err := NewDriftCheckGenerator(driftCheckConfig(false), t.TempDir()).Generate()
+ require.NoError(t, err)
+ assert.NotContains(t, content, "upload-artifact")
+
+ _, err = NewDriftCheckGenerator(driftCheckConfig(false), t.TempDir()).GenerateComment()
+ require.Error(t, err, "comment companion must not generate when comment is disabled")
+}
+
+// TestDriftCheckGenerator_Comment_TrustedMetadata is the keystone security test.
+// It proves the workflow_run comment companion derives the target PR number ONLY
+// from trusted workflow_run run metadata, never from the fork-controlled
+// artifact, and never checks out PR head code.
+func TestDriftCheckGenerator_Comment_TrustedMetadata(t *testing.T) {
+ content, err := NewDriftCheckGenerator(driftCheckConfig(true), t.TempDir()).GenerateComment()
+ require.NoError(t, err)
+
+ assert.True(t, strings.HasPrefix(content, GeneratedFileMarker))
+ assert.Contains(t, content, "on:\n workflow_run:")
+ assert.Contains(t, content, `workflows: ["Cascade Drift Check"]`)
+ assert.Contains(t, content, "permissions: {}", "top-level permissions must be empty")
+ assert.Contains(t, content, "pull-requests: write")
+ assert.Contains(t, content, "actions: read")
+ assert.Contains(t, content, "if: github.event.workflow_run.event == 'pull_request'")
+
+ // Trusted-metadata derivation: PR number comes from run.pull_requests or the
+ // head-SHA lookup, NEVER from the downloaded artifact.
+ assert.Contains(t, content, "const run = context.payload.workflow_run;")
+ assert.Contains(t, content, "prNumber = run.pull_requests[0].number;")
+ assert.Contains(t, content, "listPullRequestsAssociatedWithCommit")
+ assert.Contains(t, content, "commit_sha: run.head_sha")
+ assert.Contains(t, content, "pr.head.sha === run.head_sha")
+
+ // The artifact must only be read as data (report + exit flag), never used to
+ // select the PR. issue_number must resolve from the trusted prNumber, and the
+ // companion must never check out or execute PR head code.
+ assert.Contains(t, content, "issue_number: prNumber")
+ assert.NotContains(t, content, "actions/checkout", "comment job must never check out PR head")
+ assert.NotContains(t, content, "ref:", "comment job must never reference a PR ref to check out")
+}
+
+// TestDriftCheckGenerator_Deterministic proves byte-stability across repeated
+// generation, guarding the determinism the verify path depends on.
+func TestDriftCheckGenerator_Deterministic(t *testing.T) {
+ cfg := driftCheckConfig(true)
+ g := NewDriftCheckGenerator(cfg, t.TempDir())
+
+ check1, err := g.Generate()
+ require.NoError(t, err)
+ check2, err := g.Generate()
+ require.NoError(t, err)
+ assert.Equal(t, check1, check2)
+
+ comment1, err := g.GenerateComment()
+ require.NoError(t, err)
+ comment2, err := g.GenerateComment()
+ require.NoError(t, err)
+ assert.Equal(t, comment1, comment2)
+}
+
+// TestDriftCheckGenerator_PinModeSHA proves third-party actions are SHA-pinned
+// when pin_mode is sha, matching how cascade pins actions elsewhere.
+func TestDriftCheckGenerator_PinModeSHA(t *testing.T) {
+ cfg := driftCheckConfig(true)
+ cfg.PinMode = config.PinModeSHA
+ g := NewDriftCheckGenerator(cfg, t.TempDir())
+
+ check, err := g.Generate()
+ require.NoError(t, err)
+ assert.Contains(t, check, "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02")
+
+ comment, err := g.GenerateComment()
+ require.NoError(t, err)
+ assert.Contains(t, comment, "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093")
+ assert.Contains(t, comment, "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b")
+}
+
+// TestDriftCheckGenerator_Actionlint runs actionlint over both generated files.
+// Skipped when actionlint is not installed so the suite stays hermetic.
+func TestDriftCheckGenerator_Actionlint(t *testing.T) {
+ bin, err := exec.LookPath("actionlint")
+ if err != nil {
+ t.Skip("actionlint not installed")
+ }
+
+ g := NewDriftCheckGenerator(driftCheckConfig(true), t.TempDir())
+ check, err := g.Generate()
+ require.NoError(t, err)
+ comment, err := g.GenerateComment()
+ require.NoError(t, err)
+
+ dir := t.TempDir()
+ wfDir := filepath.Join(dir, ".github", "workflows")
+ require.NoError(t, os.MkdirAll(wfDir, 0755))
+ checkPath := filepath.Join(wfDir, "cascade-drift-check.yaml")
+ commentPath := filepath.Join(wfDir, "cascade-drift-comment.yaml")
+ require.NoError(t, os.WriteFile(checkPath, []byte(check), 0644))
+ require.NoError(t, os.WriteFile(commentPath, []byte(comment), 0644))
+
+ gitInit := exec.Command("git", "init", "-q")
+ gitInit.Dir = dir
+ require.NoError(t, gitInit.Run(), "git init for actionlint project root")
+
+ cmd := exec.Command(bin, "-shellcheck=", checkPath, commentPath)
+ cmd.Dir = dir
+ out, runErr := cmd.CombinedOutput()
+ assert.NoError(t, runErr, "actionlint reported issues:\n%s", string(out))
+}
diff --git a/internal/generate/plan.go b/internal/generate/plan.go
index bd7d9d1..ee23f8b 100644
--- a/internal/generate/plan.go
+++ b/internal/generate/plan.go
@@ -161,7 +161,25 @@ func Plan(opts PlanOptions) ([]PlannedFile, error) {
planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-pr-preview.yaml", Content: content})
}
- // 9. composite action -> baseDir/.github/actions//action.yaml.
+ // 9. drift-check -> .github/workflows/cascade-drift-check.yaml when enabled,
+ // plus the fork-safe comment companion when drift_check.comment is set.
+ if gen := NewDriftCheckGenerator(cfg, baseDir); gen.Enabled() {
+ content, err = gen.Generate()
+ if err != nil {
+ return nil, fmt.Errorf("generating drift-check workflow: %w", err)
+ }
+ planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-drift-check.yaml", Content: content})
+
+ if gen.commentEnabled() {
+ commentContent, cerr := gen.GenerateComment()
+ if cerr != nil {
+ return nil, fmt.Errorf("generating drift-comment workflow: %w", cerr)
+ }
+ planned = append(planned, PlannedFile{Path: ".github/workflows/cascade-drift-comment.yaml", Content: commentContent})
+ }
+ }
+
+ // 10. composite action -> baseDir/.github/actions//action.yaml.
action, err := RenderLocalActions(baseDir, cfg)
if err != nil {
return nil, fmt.Errorf("rendering local actions: %w", err)
diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json
index b0b4b7b..11b3e22 100644
--- a/internal/schema/manifest.schema.json
+++ b/internal/schema/manifest.schema.json
@@ -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"],
@@ -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,
diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json
index b0b4b7b..11b3e22 100644
--- a/schema/manifest.schema.json
+++ b/schema/manifest.schema.json
@@ -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"],
@@ -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,