Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/actions/manage-release/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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"
Expand All @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/generate/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
125 changes: 125 additions & 0 deletions internal/generate/dry_run_finalize_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
36 changes: 35 additions & 1 deletion internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
23 changes: 12 additions & 11 deletions internal/generate/own_repo_release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down Expand Up @@ -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.
Expand Down