diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index ea715192..48e991d7 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -383,7 +383,9 @@ func generateStubWorkflow(name, scenarioTag string) string { // generator discovers them and threads the matching preflight outputs through // each deploy's with: block. image_tag drives the source_image_tag passthrough // and image_digest drives the source_image_digest passthrough in the generated - // promote workflow. + // promote workflow. dry_run is declared so that deploys with supports_dry_run + // enabled receive the null-safe passthrough from the orchestrate workflow. + // Declaring it here is harmless for callbacks that do not use it. return fmt.Sprintf(`name: %s on: workflow_call: @@ -400,6 +402,9 @@ on: image_digest: required: false type: string + dry_run: + required: false + type: boolean jobs: %s: runs-on: ubuntu-latest diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index c20a7526..e5138ece 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -112,6 +112,7 @@ type DeployConfig struct { DependsOn []string `yaml:"depends_on"` OptionalDependsOn []string `yaml:"optional_depends_on,omitempty"` TimeoutMinutes int `yaml:"timeout_minutes,omitempty"` + SupportsDryRun bool `yaml:"supports_dry_run,omitempty"` RunsOn any `yaml:"runs_on,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty"` Concurrency *ConcurrencySpec `yaml:"concurrency,omitempty"` diff --git a/e2e/scenarios/orchestrate/dry-run-input-expression.yaml b/e2e/scenarios/orchestrate/dry-run-input-expression.yaml new file mode 100644 index 00000000..e5ead247 --- /dev/null +++ b/e2e/scenarios/orchestrate/dry-run-input-expression.yaml @@ -0,0 +1,47 @@ +name: "Dry-Run Passthrough Uses Null-Safe Expression" +description: | + Verifies that the generator emits the null-safe github.event.inputs.dry_run + accessor when a deploy callback has supports_dry_run: true. + + The orchestrate workflow is triggered by push, schedule, and workflow_run + events -- none of which populate the inputs context. A bare inputs.dry_run + expression renders as an empty string on those triggers, which breaks the + boolean dispatch to the reusable callback workflow. The generator must use + github.event.inputs.dry_run instead so the value is null (and the boolean + coercion to false) rather than an empty string that fails type validation. + + Generator-output verification scenario; the assertion runs on the staged repo + after StageRepoFromConfig generates workflows but before any orchestrate run, + so Docker and act are not required to validate the generated YAML. + +config: + trunk_branch: main + environments: ["dev", "prod"] + builds: + - name: app + workflow: build-app.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy-app.yaml + triggers: [] + supports_dry_run: true + +steps: + - name: "Initial commit; assert dry_run passthrough uses null-safe accessor" + action: commit + commit: + message: "feat: add app build and deploy with dry-run support" + files: + src/main.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/orchestrate.yaml" + contains: + # The null-safe accessor works across push, schedule, and workflow_run triggers. + - "dry_run: ${{ github.event.inputs.dry_run }}" + not_contains: + # The bare inputs accessor renders empty on non-dispatch triggers and must not appear. + - "dry_run: ${{ inputs.dry_run }}" diff --git a/internal/generate/dry_run_test.go b/internal/generate/dry_run_test.go index 39fa2f7a..4c36c4b3 100644 --- a/internal/generate/dry_run_test.go +++ b/internal/generate/dry_run_test.go @@ -175,9 +175,12 @@ on: content, err := gen.Generate() require.NoError(t, err) - // dry_run must be forwarded in the with: block to the deploy job. + // dry_run must be forwarded in the with: block to the deploy job using the + // null-safe github.event.inputs accessor. The bare inputs.dry_run form renders + // empty on push/schedule/workflow_run, which is invalid for a boolean callback + // input and fails the reusable-workflow dispatch. assert.Contains(t, content, - "dry_run: ${{ inputs.dry_run }}", + "dry_run: ${{ github.event.inputs.dry_run }}", "orchestrate generator must pass dry_run to a supports_dry_run callback") } @@ -219,7 +222,7 @@ on: deploySection := extractJobSection(t, content, "deploy-app:") assert.NotContains(t, deploySection, - "dry_run: ${{ inputs.dry_run }}", + "dry_run: ${{ github.event.inputs.dry_run }}", "non-supports_dry_run callback must not receive dry_run in orchestrate") } diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 91fbd02b..60f6131c 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -1091,8 +1091,15 @@ func (g *Generator) writeWithInputs(sb *strings.Builder, info CallbackInfo) { // When a callback opts in to dry-run emulation, pass the dry_run dispatch // input through so the callback can emulate internally instead of being skipped. + // Use github.event.inputs.dry_run rather than the bare inputs.dry_run context: + // orchestrate is triggered by push/schedule/workflow_run as well as dispatch, + // and on the non-dispatch events the inputs context is null so ${{ inputs.dry_run }} + // renders empty. Passing "" into a callback's boolean dry_run input fails the + // reusable-workflow dispatch. The github.event.inputs accessor is null-safe on + // those events (the callback falls back to its dry_run default), and on dispatch + // it still forwards the operator's value. if info.SupportsDryRun { - inputs = append(inputs, " dry_run: ${{ inputs.dry_run }}") + inputs = append(inputs, " dry_run: ${{ github.event.inputs.dry_run }}") } // For build callbacks with a matrix, pass each dimension's current value to