From dfb26906b41edc3953b0eb5b2ee547da288f4443 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 21 Jun 2026 03:15:12 -0400 Subject: [PATCH] feat: generate an opt-in PR drift-check workflow Signed-off-by: Joshua Temple --- docs/public/manifest.schema.json | 10 + docs/src/content/docs/cli-reference.md | 4 +- docs/src/content/docs/configuration.md | 25 ++ e2e/harness/scenario.go | 4 + e2e/scenarios/28-drift-check.yaml | 61 +++++ internal/config/drift_check_test.go | 30 +++ internal/config/schema_v1.go | 13 + internal/config/types.go | 1 + internal/generate/command.go | 40 +++ internal/generate/drift_check.go | 335 +++++++++++++++++++++++++ internal/generate/drift_check_test.go | 181 +++++++++++++ internal/generate/plan.go | 20 +- internal/schema/manifest.schema.json | 10 + schema/manifest.schema.json | 10 + 14 files changed, 742 insertions(+), 2 deletions(-) create mode 100644 e2e/scenarios/28-drift-check.yaml create mode 100644 internal/config/drift_check_test.go create mode 100644 internal/generate/drift_check.go create mode 100644 internal/generate/drift_check_test.go 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,