diff --git a/.github/actions/manage-release/action.yaml b/.github/actions/manage-release/action.yaml index cd68425..4004bc9 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 60ef16a..507407a 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b8397..9c4776b 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/internal/generate/actions.go b/internal/generate/actions.go index 041c9bd..b2e9b69 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 0000000..563528f --- /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 bd3f60b..d440057 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 00d4f3c..1357857 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 a5d66f9..c2789b0 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 cb37233..e616729 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.