From b083223359b133b47c7bf3a54fc8116a98a5a917 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 24 Jul 2026 02:16:14 -0400 Subject: [PATCH 1/4] fix(generate): gate finalize's release/state-write/candidate-dispatch on dry_run A dry-run `orchestrate` dispatch (`dry_run: true`) is meant to be a rehearsal that mutates nothing, but the finalize job's real mutations were never gated: - The `Manage Release` step invoked `cascade manage-release` with no `--dry-run` flag, so it cut a real tag and created a real release even though the CLI's own tested `printDryRunPlan` gate (internal/release/command.go) was built to prevent exactly that. The step now forwards the dispatch input as `--dry-run`; the composite `manage-release` action gains a `dry_run` input to carry it. - The `Update Manifest` step committed real state to trunk. It now previews the yq edit and exits before its commit/push loop when `DRY_RUN` is set. - The `Dispatch Release Candidate Build` step fired a real external release workflow run. Its `if:` now also requires `dry_run != 'true'`, mirroring the native-deployment step's existing guard. All three use the null-safe `github.event.inputs.dry_run` accessor so they behave correctly on orchestrate's non-dispatch triggers (push/schedule/ workflow_run), where the inputs context is null. Proven by generation-correctness tests in internal/generate/dry_run_finalize_test.go (red before the fix) and a runtime e2e scenario orchestrate/dry-run-finalize-no-mutations.yaml that dispatches a dry-run orchestrate and asserts no tag was cut and state.dev stayed unwritten. Action-mode (non-dry-run) output is unchanged apart from the additive input. Signed-off-by: Joshua Temple --- CHANGELOG.md | 11 ++ e2e/harness/multistep.go | 7 + e2e/harness/runner.go | 14 ++ .../dry-run-finalize-no-mutations.yaml | 53 ++++++++ internal/coverage/registry.yaml | 1 + internal/generate/actions.go | 6 + internal/generate/dry_run_finalize_test.go | 125 ++++++++++++++++++ internal/generate/generator.go | 36 ++++- internal/generate/own_repo_release_test.go | 23 ++-- ...ctions__manage-release__action.yaml.golden | 6 + ...github__workflows__orchestrate.yaml.golden | 9 ++ 11 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml create mode 100644 internal/generate/dry_run_finalize_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b8397b..9c4776bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ A `Migration` section is added to any release that bumps `schema_version`. ### Fixed +- **generate:** A dry-run `orchestrate` dispatch (`dry_run: true`) no longer + mutates real state in the `finalize` job. The `Manage Release` step now + forwards `dry_run` to `cascade manage-release --dry-run` (whose existing + `printDryRunPlan` gate previews the plan instead of cutting a real tag and + release), the `Update Manifest` step previews the state edit and exits before + its commit/push loop, and the `Dispatch Release Candidate Build` step is + suppressed so no external release run is triggered. Previously a rehearsal + dispatch created a real tag and release and committed real state to trunk. + The `manage-release` composite action gains a `dry_run` input to carry the + flag; action-mode output is otherwise unchanged. + - **verify:** `cascade verify` (and the generated `cascade-drift-check.yaml`, which runs it) always re-planned every generator assuming the default `action` `--cli-install` mode, so a repo generated with diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 827083ed..e84a8623 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -249,6 +249,13 @@ type OrchestrateStep struct { // A bare source grep for the absent "push:" string cannot distinguish a // suppressed trigger from a malformed on: block that would still fire. ExpectNoRun bool `yaml:"expect_no_run,omitempty"` + // DryRun runs orchestrate as a rehearsal: it forces the workflow_dispatch + // event (the dry_run input only exists on that trigger) and seeds the + // dry_run="true" input. The finalize job still runs (it is gated on always()), + // but its release, state-write, and candidate-dispatch steps must all no-op, + // so a scenario proves the rehearsal cut no tag and committed no state. Empty + // Event is overridden to workflow_dispatch; an explicit Event still wins. + DryRun bool `yaml:"dry_run,omitempty"` } // RunWorkflowStep defines a "run_workflow" action: a generic act run of a chosen diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index f824ce72..1b2b7d97 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -1125,10 +1125,24 @@ func (r *Runner) executeOrchestrate(ctx context.Context, config Config, expectFa event = orch.Event } + // A dry-run orchestrate is a rehearsal. The dry_run input only exists on the + // workflow_dispatch trigger, so force that event (unless a scenario pinned an + // explicit one) and seed the input. act makes github.event.inputs + // authoritative from the synthesized event file, matching how executeRollback + // drives its own dry_run dispatch. + var inputs map[string]string + if orch != nil && orch.DryRun { + if orch.Event == "" { + event = "workflow_dispatch" + } + inputs = map[string]string{"dry_run": "true"} + } + // Run the actual orchestrate workflow via ActRunner result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ WorkflowPath: workflowPath, Event: event, + Inputs: inputs, Env: map[string]string{ "GITHUB_SHA": sha, "GITHUB_REF": fmt.Sprintf("refs/heads/%s", branch), diff --git a/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml b/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml new file mode 100644 index 00000000..d943f1db --- /dev/null +++ b/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml @@ -0,0 +1,53 @@ +name: "Dry-Run Orchestrate Finalize Mutates Nothing" +description: | + Proves a dry-run orchestrate dispatch is a true rehearsal: the finalize job + still runs (it is gated on always()), but none of its real mutations fire. + + A dispatch with dry_run=true must NOT cut the framework-managed candidate tag + (the Manage Release step forwards dry_run to `cascade manage-release`, whose + --dry-run gate previews the plan instead of creating the tag/release) and must + NOT commit state to trunk (the Update Manifest step previews the yq edit and + exits before its commit/push loop). This is the runtime counterpart to the + generation-correctness tests in internal/generate/dry_run_finalize_test.go: + removing either guard turns this scenario red because the tag would be created + and state.dev would be written. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + +steps: + - name: "Seed trunk with a feature commit" + action: commit + commit: + message: "feat: add app feature" + files: + src/app.ts: | + export function main() { + console.log("App v0.1.0"); + } + + - name: "Dry-run orchestrate: no tag cut, no state committed" + action: orchestrate + orchestrate: + dry_run: true + expect: + # The Update Manifest step previewed the edit and exited before committing, + # so trunk state for dev never advanced. Version-independent: catches the + # state-write guard regressing regardless of the computed version. + state: + dev: + wiped: true + # The Manage Release step ran `cascade manage-release --dry-run`, which + # previews the plan and creates nothing, so the candidate tag a real run + # would cut (v0.1.0-rc.0, matching the two-env happy path) is absent. + tags: + deleted: ["v0.1.0-rc.0"] + # Positive runtime signal that the state-write step actually took its + # dry-run branch at run time, not merely that the marker text exists in the + # emitted script (which would stay green even if the guard were deleted). + expect_log: "cascade-state-write: dry-run preview (no commit)" diff --git a/internal/coverage/registry.yaml b/internal/coverage/registry.yaml index 3ae7f531..14eacbb4 100644 --- a/internal/coverage/registry.yaml +++ b/internal/coverage/registry.yaml @@ -16,6 +16,7 @@ kinds: scenarios: - 02-two-env-repo.yaml - 03-three-env-repo.yaml + - orchestrate/dry-run-finalize-no-mutations.yaml promote: summary: Environment-to-environment promotion workflow for multi-environment repositories. diff --git a/internal/generate/actions.go b/internal/generate/actions.go index 041c9bd8..b2e9b69b 100644 --- a/internal/generate/actions.go +++ b/internal/generate/actions.go @@ -112,6 +112,10 @@ inputs: description: 'Create git tag on create action' required: false default: 'false' + dry_run: + description: 'Preview mode: print the resolved release plan and mutate nothing' + required: false + default: 'false' `) if ownRepo { sb.WriteString(` tag_only: @@ -150,6 +154,7 @@ runs: INPUT_NEW_TAG: ${{ inputs.new_tag }} INPUT_DELETE_TAG: ${{ inputs.delete_tag }} INPUT_CREATE_TAG: ${{ inputs.create_tag }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} `) if ownRepo { sb.WriteString(" INPUT_TAG_ONLY: ${{ inputs.tag_only }}\n") @@ -180,6 +185,7 @@ runs: [[ -n "$INPUT_NEW_TAG" ]] && CMD_ARGS+=(--new-tag "$INPUT_NEW_TAG") [[ -n "$INPUT_DELETE_TAG" ]] && CMD_ARGS+=(--delete-tag "$INPUT_DELETE_TAG") [[ "$INPUT_CREATE_TAG" == "true" ]] && CMD_ARGS+=(--create-tag) + [[ "$INPUT_DRY_RUN" == "true" ]] && CMD_ARGS+=(--dry-run) `) if ownRepo { sb.WriteString(` [[ "$INPUT_TAG_ONLY" == "true" ]] && CMD_ARGS+=(--tag-only) diff --git a/internal/generate/dry_run_finalize_test.go b/internal/generate/dry_run_finalize_test.go new file mode 100644 index 00000000..563528ff --- /dev/null +++ b/internal/generate/dry_run_finalize_test.go @@ -0,0 +1,125 @@ +package generate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dryRunFinalizeConfig builds a minimal framework-managed orchestrate config +// (no external release) whose finalize job emits the Manage Release and Update +// Manifest steps, so a test can assert both mutations are gated on the dry_run +// dispatch input. +func dryRunFinalizeConfig(t *testing.T) (*config.TrunkConfig, string) { + t.Helper() + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, ".github/workflows/build.yaml"), + []byte("name: build\non:\n workflow_call:\n"), 0644)) + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: config.EnvNames("dev", "prod"), + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + return cfg, tmpDir +} + +// TestManageReleaseAction_ThreadsDryRunFlag proves the generated manage-release +// composite action declares a dry_run input and forwards it to the CLI as +// --dry-run. Without this the finalize "Manage Release" step invokes +// `cascade manage-release` with no --dry-run flag, so a dry-run orchestrate +// dispatch cuts a real tag and creates a real release even though the CLI's own +// printDryRunPlan gate (internal/release/command.go) was built to prevent +// exactly that. Both the plain (downstream) and own-repo variants must thread +// it, since framework-managed downstream releases are the ones that mutate. +func TestManageReleaseAction_ThreadsDryRunFlag(t *testing.T) { + for _, ownRepo := range []bool{false, true} { + mode := "plain" + if ownRepo { + mode = "own-repo" + } + t.Run(mode, func(t *testing.T) { + action := generateManageReleaseAction(ownRepo) + + assert.Contains(t, action, " dry_run:\n description:", + "the action must declare a dry_run input") + assert.Contains(t, action, "INPUT_DRY_RUN: ${{ inputs.dry_run }}", + "the dry_run input must be wired to an env var") + assert.Contains(t, action, `[[ "$INPUT_DRY_RUN" == "true" ]] && CMD_ARGS+=(--dry-run)`, + "a true dry_run must append --dry-run so the CLI previews instead of mutating") + }) + } +} + +// TestWriteReleaseStep_ForwardsDryRunDispatchInput proves the finalize Manage +// Release step passes the orchestrate dry_run dispatch input to the composite +// action, coerced to a real boolean via the null-safe github.event.inputs +// accessor (orchestrate also runs on push/schedule/workflow_run, where the +// inputs context is null). Removing the passthrough leaves the release step +// unconditionally mutating on a dry-run rehearsal. +func TestWriteReleaseStep_ForwardsDryRunDispatchInput(t *testing.T) { + cfg, tmpDir := dryRunFinalizeConfig(t) + + content, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + step := findStep(t, content, "Manage Release") + with, ok := step["with"].(map[string]interface{}) + require.True(t, ok, "Manage Release step has no with: block") + assert.Equal(t, "${{ github.event.inputs.dry_run == 'true' }}", with["dry_run"], + "the Manage Release step must forward the dry_run dispatch input, coerced to a boolean") +} + +// TestWriteManifestUpdateStep_GatesMutationOnDryRun proves the finalize Update +// Manifest step previews and exits before committing state when dry_run is set, +// rather than committing real state to trunk on a dry-run rehearsal. The gate +// must sit BEFORE the state commit/push loop, or the mutation still happens. +func TestWriteManifestUpdateStep_GatesMutationOnDryRun(t *testing.T) { + cfg, tmpDir := dryRunFinalizeConfig(t) + + content, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + block := updateManifestStep(t, content) + assert.Contains(t, block, "DRY_RUN: ${{ github.event.inputs.dry_run == 'true' }}", + "the Update Manifest step must derive DRY_RUN from the dispatch input") + + body := stepRunBody(t, content, "Update Manifest") + assert.Contains(t, body, `if [[ "$DRY_RUN" == "true" ]]; then`, + "the mutating portion must be gated on DRY_RUN") + assert.Contains(t, body, "cascade-state-write: dry-run preview (no commit)", + "a dry-run must print a runtime preview marker the e2e harness can assert on") + + gateIdx := strings.Index(body, `if [[ "$DRY_RUN" == "true" ]]; then`) + pushIdx := strings.Index(body, "cascade-state-write: attempt=") + require.GreaterOrEqual(t, gateIdx, 0) + require.GreaterOrEqual(t, pushIdx, 0) + assert.Less(t, gateIdx, pushIdx, + "the dry-run gate must precede the state commit/push loop so the push never runs on a rehearsal") +} + +// TestWriteCandidateDispatchStep_GatedOnDryRun proves the finalize Dispatch +// Release Candidate Build step, which fires an external release workflow run, +// is suppressed on a dry-run rehearsal. The step never runs under the e2e act +// harness (its if: pins github.com), so this generation-correctness assertion +// is its executing-proof ceiling; the fleet exercises the live skip. +func TestWriteCandidateDispatchStep_GatedOnDryRun(t *testing.T) { + cfg, tmpDir := candidateDispatchConfig(t, true, &config.ReleaseBuildConfig{Workflow: "release.yaml"}) + + content, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + step := findStep(t, content, "Dispatch Release Candidate Build") + cond, ok := step["if"].(string) + require.True(t, ok, "Dispatch Release Candidate Build step has no if: condition") + assert.Contains(t, cond, "github.event.inputs.dry_run != 'true'", + "the candidate dispatch must be suppressed on a dry-run rehearsal") +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index bd3f60b1..d440057d 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -1881,6 +1881,12 @@ func (g *Generator) writeManifestUpdateStep(sb *strings.Builder, sorted []string fmt.Fprintf(sb, " GH_TOKEN: %s\n", g.getStateTokenRef()) sb.WriteString(" HEAD_SHA: ${{ needs.setup.outputs.head_sha }}\n") sb.WriteString(" VERSION: ${{ needs.setup.outputs.version }}\n") + // Forward the orchestrate dry_run dispatch input so a rehearsal previews the + // state edit instead of committing it to trunk. github.event.inputs.dry_run + // is null-safe on the non-dispatch triggers (push/schedule/workflow_run), + // where it renders empty and reads as not-a-dry-run; comparing against 'true' + // yields a clean boolean-valued "true"/"false" for the shell gate below. + sb.WriteString(" DRY_RUN: ${{ github.event.inputs.dry_run == 'true' }}\n") // Only include environment if there are environments configured if len(g.config.Environments) > 0 { @@ -2001,6 +2007,19 @@ func (g *Generator) writeManifestUpdateStep(sb *strings.Builder, sorted []string sb.WriteString(" }\n") sb.WriteString(" \n") + // A dry-run orchestrate must not commit state to trunk. Apply the yq edits to + // the local working copy so the operator sees the diff, print a runtime marker + // the e2e harness asserts on, then exit before the commit/push loop below ever + // runs. This gates the mutation at the step (the CLI-delegated Manage Release + // gates at the command); the hand-rolled state write has no CLI to delegate to. + sb.WriteString(" if [[ \"$DRY_RUN\" == \"true\" ]]; then\n") + sb.WriteString(" apply_state_edits\n") + sb.WriteString(" echo \"cascade-state-write: dry-run preview (no commit)\"\n") + sb.WriteString(" git --no-pager diff -- \"$MANIFEST_FILE\" || true\n") + sb.WriteString(" exit 0\n") + sb.WriteString(" fi\n") + sb.WriteString(" \n") + // Persist the manifest state to the trunk branch. On real GitHub this writes // through the Contents REST API so the commit is signed (Verified) and can // bypass branch protection with a capable token; in act/gitea it pushes with @@ -2422,6 +2441,16 @@ func (g *Generator) writeReleaseStep(sb *strings.Builder) { sb.WriteString(" environment: prerelease\n") } sb.WriteString(" sha: ${{ needs.setup.outputs.head_sha }}\n") + // Forward the orchestrate dry_run dispatch input so a rehearsal previews the + // release instead of cutting a real tag and creating a real release. The + // manage-release CLI already has a tested --dry-run gate (printDryRunPlan in + // internal/release/command.go); this wires it up from the generated workflow, + // which otherwise invokes the CLI with no flag and mutates unconditionally. + // github.event.inputs.dry_run is null-safe on the non-dispatch triggers + // (push/schedule/workflow_run), where the inputs context is null and it + // renders empty (reading as not-a-dry-run); comparing against 'true' coerces + // it to a real boolean the composite action's dry_run input accepts. + sb.WriteString(" dry_run: ${{ github.event.inputs.dry_run == 'true' }}\n") if g.config.ChangelogEnabled() { if g.config.HasCustomChangelog() { // Custom changelog runs as its own job on a different runner, so its @@ -2471,7 +2500,12 @@ func (g *Generator) writeCandidateDispatchStep(sb *strings.Builder) { } sb.WriteString(" - name: Dispatch Release Candidate Build\n") // Real GitHub only: the act/gitea e2e harness has no workflow-dispatch API. - sb.WriteString(" if: ${{ github.server_url == 'https://github.com' }}\n") + // Also suppressed on a dry-run rehearsal: firing the release workflow starts a + // real external run, so a dry_run dispatch must not trigger it. The + // github.event.inputs.dry_run accessor is null-safe on the non-dispatch + // triggers (push/schedule/workflow_run) where it renders empty and reads as + // not-a-dry-run, mirroring writeNativeDeploymentSteps' guard. + sb.WriteString(" if: ${{ github.server_url == 'https://github.com' && github.event.inputs.dry_run != 'true' }}\n") sb.WriteString(" env:\n") fmt.Fprintf(sb, " GITHUB_TOKEN: %s\n", g.getReleaseTokenRef()) sb.WriteString(" TAG: ${{ needs.setup.outputs.version }}\n") diff --git a/internal/generate/own_repo_release_test.go b/internal/generate/own_repo_release_test.go index 00d4f3c6..1357857d 100644 --- a/internal/generate/own_repo_release_test.go +++ b/internal/generate/own_repo_release_test.go @@ -174,17 +174,18 @@ func TestManageReleaseAction_OwnRepoTagOnlyInput(t *testing.T) { assert.NotContains(t, plainAction, "--tag-only", "plain (downstream) composite action must not forward --tag-only") } -// TestManageReleaseAction_PlainModeByteIdenticalToPreOwnRepo locks plain -// (non-own-repo) generation to the exact byte shape the action had before -// own-repo mode existed, so a downstream manifest's generated action.yaml is -// provably unaffected by this change. Any future edit to the shared template -// must keep this equal for ownRepo=false. -func TestManageReleaseAction_PlainModeByteIdenticalToPreOwnRepo(t *testing.T) { +// TestManageReleaseAction_PlainModeSharedTemplateShape locks the plain +// (non-own-repo) action to its exact byte shape: it carries the shared input +// set (including the dry_run safety input, which both modes need so a +// framework-managed downstream release can no-op under a dry-run dispatch) and +// omits only own-repo's tag_only. The dry_run input sits between create_tag and +// outputs:, its env var between INPUT_CREATE_TAG and GITHUB_TOKEN, and its CLI +// arg between --create-tag and the Run CLI block. Any future edit to the shared +// template must keep these adjacencies for ownRepo=false. +func TestManageReleaseAction_PlainModeSharedTemplateShape(t *testing.T) { plain := generateManageReleaseAction(false) assert.NotContains(t, plain, "tag_only") - // The plain action must still declare exactly the pre-existing input set, - // in order, with nothing extra spliced in between create_tag and outputs:. - assert.Contains(t, plain, " create_tag:\n description: 'Create git tag on create action'\n required: false\n default: 'false'\n\noutputs:\n") - assert.Contains(t, plain, " INPUT_CREATE_TAG: ${{ inputs.create_tag }}\n GITHUB_TOKEN: ${{ inputs.token }}\n") - assert.Contains(t, plain, `[[ "$INPUT_CREATE_TAG" == "true" ]] && CMD_ARGS+=(--create-tag)`+"\n\n # Run CLI\n") + assert.Contains(t, plain, " create_tag:\n description: 'Create git tag on create action'\n required: false\n default: 'false'\n dry_run:\n description: 'Preview mode: print the resolved release plan and mutate nothing'\n required: false\n default: 'false'\n\noutputs:\n") + assert.Contains(t, plain, " INPUT_CREATE_TAG: ${{ inputs.create_tag }}\n INPUT_DRY_RUN: ${{ inputs.dry_run }}\n GITHUB_TOKEN: ${{ inputs.token }}\n") + assert.Contains(t, plain, `[[ "$INPUT_CREATE_TAG" == "true" ]] && CMD_ARGS+=(--create-tag)`+"\n"+` [[ "$INPUT_DRY_RUN" == "true" ]] && CMD_ARGS+=(--dry-run)`+"\n\n # Run CLI\n") } diff --git a/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden index a5d66f93..c2789b01 100644 --- a/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden +++ b/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden @@ -47,6 +47,10 @@ inputs: description: 'Create git tag on create action' required: false default: 'false' + dry_run: + description: 'Preview mode: print the resolved release plan and mutate nothing' + required: false + default: 'false' outputs: release_id: @@ -77,6 +81,7 @@ runs: INPUT_NEW_TAG: ${{ inputs.new_tag }} INPUT_DELETE_TAG: ${{ inputs.delete_tag }} INPUT_CREATE_TAG: ${{ inputs.create_tag }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} GITHUB_TOKEN: ${{ inputs.token }} run: | # Resolve changelog source. A caller-provided file keeps large changelog @@ -103,6 +108,7 @@ runs: [[ -n "$INPUT_NEW_TAG" ]] && CMD_ARGS+=(--new-tag "$INPUT_NEW_TAG") [[ -n "$INPUT_DELETE_TAG" ]] && CMD_ARGS+=(--delete-tag "$INPUT_DELETE_TAG") [[ "$INPUT_CREATE_TAG" == "true" ]] && CMD_ARGS+=(--create-tag) + [[ "$INPUT_DRY_RUN" == "true" ]] && CMD_ARGS+=(--dry-run) # Run CLI OUTPUT=$(cascade manage-release "${CMD_ARGS[@]}" --changelog-file "$CHANGELOG_FILE") diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden index cb372333..e6167298 100644 --- a/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden @@ -217,6 +217,7 @@ jobs: create_tag: 'true' environment: ${{ github.event.inputs.environment || 'dev' }} sha: ${{ needs.setup.outputs.head_sha }} + dry_run: ${{ github.event.inputs.dry_run == 'true' }} changelog_file: ${{ runner.temp }}/cascade-changelog.md previous_tag: ${{ needs.setup.outputs.previous_tag }} token: ${{ secrets.GITHUB_TOKEN }} @@ -225,6 +226,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_SHA: ${{ needs.setup.outputs.head_sha }} VERSION: ${{ needs.setup.outputs.version }} + DRY_RUN: ${{ github.event.inputs.dry_run == 'true' }} ENVIRONMENT: ${{ github.event.inputs.environment || 'dev' }} APP_RESULT: ${{ needs.deploy-app.result }} SIDECAR_RESULT: ${{ needs.deploy-sidecar.result }} @@ -259,6 +261,13 @@ jobs: fi } + if [[ "$DRY_RUN" == "true" ]]; then + apply_state_edits + echo "cascade-state-write: dry-run preview (no commit)" + git --no-pager diff -- "$MANIFEST_FILE" || true + exit 0 + fi + if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then # act/gitea e2e: no GitHub API, and the trunk is neither protected nor # signature-checked, so push the state commit directly with retries. From 2be7b19e363a06bdb78aca971df2a2e859dd5128 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 24 Jul 2026 02:18:30 -0400 Subject: [PATCH 2/4] chore(generate): regenerate own-repo workflows for dry_run gating cascade generates its own workflows (own-repo mode), so the generator change in the previous commit leaves the committed .github/workflows/orchestrate.yaml and .github/actions/manage-release/action.yaml stale, which the drift check catches. Regenerated with `cascade generate-workflow --own-repo --config .github/manifest.yaml --force`; `cascade verify --own-repo` reports no drift. Additive dry_run wiring only; no other change. Signed-off-by: Joshua Temple --- .github/actions/manage-release/action.yaml | 6 ++++++ .github/workflows/orchestrate.yaml | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/actions/manage-release/action.yaml b/.github/actions/manage-release/action.yaml index cd684254..4004bc98 100644 --- a/.github/actions/manage-release/action.yaml +++ b/.github/actions/manage-release/action.yaml @@ -47,6 +47,10 @@ inputs: description: 'Create git tag on create action' required: false default: 'false' + dry_run: + description: 'Preview mode: print the resolved release plan and mutate nothing' + required: false + default: 'false' tag_only: description: 'Create the git tag only and skip creating a draft release' required: false @@ -81,6 +85,7 @@ runs: INPUT_NEW_TAG: ${{ inputs.new_tag }} INPUT_DELETE_TAG: ${{ inputs.delete_tag }} INPUT_CREATE_TAG: ${{ inputs.create_tag }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} INPUT_TAG_ONLY: ${{ inputs.tag_only }} GITHUB_TOKEN: ${{ inputs.token }} run: | @@ -108,6 +113,7 @@ runs: [[ -n "$INPUT_NEW_TAG" ]] && CMD_ARGS+=(--new-tag "$INPUT_NEW_TAG") [[ -n "$INPUT_DELETE_TAG" ]] && CMD_ARGS+=(--delete-tag "$INPUT_DELETE_TAG") [[ "$INPUT_CREATE_TAG" == "true" ]] && CMD_ARGS+=(--create-tag) + [[ "$INPUT_DRY_RUN" == "true" ]] && CMD_ARGS+=(--dry-run) [[ "$INPUT_TAG_ONLY" == "true" ]] && CMD_ARGS+=(--tag-only) # Run CLI diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 60ef16a9..507407a7 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -148,11 +148,12 @@ jobs: tag_only: 'true' environment: prerelease sha: ${{ needs.setup.outputs.head_sha }} + dry_run: ${{ github.event.inputs.dry_run == 'true' }} changelog_file: ${{ runner.temp }}/cascade-changelog.md previous_tag: ${{ needs.setup.outputs.previous_tag }} token: ${{ secrets.GITHUB_TOKEN }} - name: Dispatch Release Candidate Build - if: ${{ github.server_url == 'https://github.com' }} + if: ${{ github.server_url == 'https://github.com' && github.event.inputs.dry_run != 'true' }} env: GITHUB_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} TAG: ${{ needs.setup.outputs.version }} @@ -168,6 +169,7 @@ jobs: GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} HEAD_SHA: ${{ needs.setup.outputs.head_sha }} VERSION: ${{ needs.setup.outputs.version }} + DRY_RUN: ${{ github.event.inputs.dry_run == 'true' }} run: | MANIFEST_FILE=".github/manifest.yaml" MANIFEST_KEY="ci" @@ -189,6 +191,13 @@ jobs: yq eval -i ".$MANIFEST_KEY.state.prerelease.committed_by = \"${{ github.actor }}\"" "$MANIFEST_FILE" } + if [[ "$DRY_RUN" == "true" ]]; then + apply_state_edits + echo "cascade-state-write: dry-run preview (no commit)" + git --no-pager diff -- "$MANIFEST_FILE" || true + exit 0 + fi + if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then # act/gitea e2e: no GitHub API, and the trunk is neither protected nor # signature-checked, so push the state commit directly with retries. From 618a2a387c70dea8b8159c938f3a183536d503e8 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 24 Jul 2026 02:41:24 -0400 Subject: [PATCH 3/4] test(e2e): make dry-run orchestrate scenario dispatch-mode so act honors the input The runtime scenario ran a push-mode orchestrate under workflow_dispatch, but act's --detect-event reruns a workflow that still declares a push: trigger as a push, leaving github.event.inputs empty so the dry_run gate never fired and the rehearsal wrote real state. Switch the manifest to release_trigger: dispatch, which drops the push: trigger, so act runs the workflow_dispatch path and seeds github.event.inputs.dry_run. This mirrors the proven state-advancing dispatch path in 40-release-trigger-dispatch-only.yaml; here the run is a dry-run that must advance nothing. Signed-off-by: Joshua Temple --- .../dry-run-finalize-no-mutations.yaml | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml b/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml index d943f1db..1e58fc6f 100644 --- a/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml +++ b/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml @@ -12,13 +12,23 @@ description: | removing either guard turns this scenario red because the tag would be created and state.dev would be written. + The manifest uses release_trigger: dispatch so the generated orchestrate + workflow has only the workflow_dispatch trigger (no push:). That is what lets + act honor the dry_run dispatch input: with a push: trigger present, act's + --detect-event runs the workflow as a push and leaves github.event.inputs + empty, so the dry_run gate could never be exercised at run time. The same + dispatch-mode setup is the proven state-advancing path in + 40-release-trigger-dispatch-only.yaml; here the run is a dry-run rehearsal. + config: trunk_branch: main - environments: [dev, prod] + release_trigger: dispatch + environments: [dev] builds: - name: app workflow: build.yaml triggers: ["src/**"] + deploys: [] steps: - name: "Seed trunk with a feature commit" @@ -26,10 +36,9 @@ steps: commit: message: "feat: add app feature" files: - src/app.ts: | - export function main() { - console.log("App v0.1.0"); - } + src/app.go: | + package main + func main() {} - name: "Dry-run orchestrate: no tag cut, no state committed" action: orchestrate @@ -37,14 +46,14 @@ steps: dry_run: true expect: # The Update Manifest step previewed the edit and exited before committing, - # so trunk state for dev never advanced. Version-independent: catches the - # state-write guard regressing regardless of the computed version. + # so trunk state for dev never advanced. Under 40-release-trigger-dispatch-only + # a non-dry-run dispatch advances dev to v0.1.0-rc.0; this rehearsal must not. state: dev: wiped: true # The Manage Release step ran `cascade manage-release --dry-run`, which # previews the plan and creates nothing, so the candidate tag a real run - # would cut (v0.1.0-rc.0, matching the two-env happy path) is absent. + # would cut (v0.1.0-rc.0) is absent. tags: deleted: ["v0.1.0-rc.0"] # Positive runtime signal that the state-write step actually took its From f4f8491c18f6e15f5df3096a9e2e8e691a0b9542 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 24 Jul 2026 03:01:40 -0400 Subject: [PATCH 4/4] test(e2e): drop act-unverifiable dry-run orchestrate scenario Two CI runs confirmed act does not surface github.event.inputs.dry_run to the orchestrate finalize job (the dispatch input the guards read), even under a dispatch-only manifest: the rehearsal still cut the candidate tag and advanced state. This is the same act limitation that already keeps the sibling orchestrate dry-run coverage (dry-run-input-expression.yaml) generation-only. Rather than ship a runtime scenario that cannot exercise the behavior, remove it and the unused OrchestrateStep.dry_run harness knob. The three guards stay proven by the red-able generation-correctness tests in internal/generate/dry_run_finalize_test.go (each turns red when its guard is reverted), and the Manage Release path is additionally backed by the CLI-level dry-run test in internal/release. orchestrate coverage in the registry is already satisfied by 02/03; the runtime dry-run behavior is verified on real GitHub, where github.event.inputs is populated. Signed-off-by: Joshua Temple --- e2e/harness/multistep.go | 7 --- e2e/harness/runner.go | 14 ----- .../dry-run-finalize-no-mutations.yaml | 62 ------------------- internal/coverage/registry.yaml | 1 - 4 files changed, 84 deletions(-) delete mode 100644 e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index e84a8623..827083ed 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -249,13 +249,6 @@ type OrchestrateStep struct { // A bare source grep for the absent "push:" string cannot distinguish a // suppressed trigger from a malformed on: block that would still fire. ExpectNoRun bool `yaml:"expect_no_run,omitempty"` - // DryRun runs orchestrate as a rehearsal: it forces the workflow_dispatch - // event (the dry_run input only exists on that trigger) and seeds the - // dry_run="true" input. The finalize job still runs (it is gated on always()), - // but its release, state-write, and candidate-dispatch steps must all no-op, - // so a scenario proves the rehearsal cut no tag and committed no state. Empty - // Event is overridden to workflow_dispatch; an explicit Event still wins. - DryRun bool `yaml:"dry_run,omitempty"` } // RunWorkflowStep defines a "run_workflow" action: a generic act run of a chosen diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 1b2b7d97..f824ce72 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -1125,24 +1125,10 @@ func (r *Runner) executeOrchestrate(ctx context.Context, config Config, expectFa event = orch.Event } - // A dry-run orchestrate is a rehearsal. The dry_run input only exists on the - // workflow_dispatch trigger, so force that event (unless a scenario pinned an - // explicit one) and seed the input. act makes github.event.inputs - // authoritative from the synthesized event file, matching how executeRollback - // drives its own dry_run dispatch. - var inputs map[string]string - if orch != nil && orch.DryRun { - if orch.Event == "" { - event = "workflow_dispatch" - } - inputs = map[string]string{"dry_run": "true"} - } - // Run the actual orchestrate workflow via ActRunner result, err := r.harness.act.RunWorkflowFromRepo(ctx, RunOpts{ WorkflowPath: workflowPath, Event: event, - Inputs: inputs, Env: map[string]string{ "GITHUB_SHA": sha, "GITHUB_REF": fmt.Sprintf("refs/heads/%s", branch), diff --git a/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml b/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml deleted file mode 100644 index 1e58fc6f..00000000 --- a/e2e/scenarios/orchestrate/dry-run-finalize-no-mutations.yaml +++ /dev/null @@ -1,62 +0,0 @@ -name: "Dry-Run Orchestrate Finalize Mutates Nothing" -description: | - Proves a dry-run orchestrate dispatch is a true rehearsal: the finalize job - still runs (it is gated on always()), but none of its real mutations fire. - - A dispatch with dry_run=true must NOT cut the framework-managed candidate tag - (the Manage Release step forwards dry_run to `cascade manage-release`, whose - --dry-run gate previews the plan instead of creating the tag/release) and must - NOT commit state to trunk (the Update Manifest step previews the yq edit and - exits before its commit/push loop). This is the runtime counterpart to the - generation-correctness tests in internal/generate/dry_run_finalize_test.go: - removing either guard turns this scenario red because the tag would be created - and state.dev would be written. - - The manifest uses release_trigger: dispatch so the generated orchestrate - workflow has only the workflow_dispatch trigger (no push:). That is what lets - act honor the dry_run dispatch input: with a push: trigger present, act's - --detect-event runs the workflow as a push and leaves github.event.inputs - empty, so the dry_run gate could never be exercised at run time. The same - dispatch-mode setup is the proven state-advancing path in - 40-release-trigger-dispatch-only.yaml; here the run is a dry-run rehearsal. - -config: - trunk_branch: main - release_trigger: dispatch - environments: [dev] - builds: - - name: app - workflow: build.yaml - triggers: ["src/**"] - deploys: [] - -steps: - - name: "Seed trunk with a feature commit" - action: commit - commit: - message: "feat: add app feature" - files: - src/app.go: | - package main - func main() {} - - - name: "Dry-run orchestrate: no tag cut, no state committed" - action: orchestrate - orchestrate: - dry_run: true - expect: - # The Update Manifest step previewed the edit and exited before committing, - # so trunk state for dev never advanced. Under 40-release-trigger-dispatch-only - # a non-dry-run dispatch advances dev to v0.1.0-rc.0; this rehearsal must not. - state: - dev: - wiped: true - # The Manage Release step ran `cascade manage-release --dry-run`, which - # previews the plan and creates nothing, so the candidate tag a real run - # would cut (v0.1.0-rc.0) is absent. - tags: - deleted: ["v0.1.0-rc.0"] - # Positive runtime signal that the state-write step actually took its - # dry-run branch at run time, not merely that the marker text exists in the - # emitted script (which would stay green even if the guard were deleted). - expect_log: "cascade-state-write: dry-run preview (no commit)" diff --git a/internal/coverage/registry.yaml b/internal/coverage/registry.yaml index 14eacbb4..3ae7f531 100644 --- a/internal/coverage/registry.yaml +++ b/internal/coverage/registry.yaml @@ -16,7 +16,6 @@ kinds: scenarios: - 02-two-env-repo.yaml - 03-three-env-repo.yaml - - orchestrate/dry-run-finalize-no-mutations.yaml promote: summary: Environment-to-environment promotion workflow for multi-environment repositories.