diff --git a/e2e/scenarios/16-pr-preview.yaml b/e2e/scenarios/16-pr-preview.yaml index 82f23850..a74a0cbc 100644 --- a/e2e/scenarios/16-pr-preview.yaml +++ b/e2e/scenarios/16-pr-preview.yaml @@ -4,11 +4,16 @@ description: | workflow that plans a preview on pull requests, with a per-PR concurrency group (#93). + The manifest declares multiple environments so the generated dry-run + step must target the first environment; orchestrate setup resolves the + version against that environment, and a bare environment would fail the + preview with `environment "" not found`. + Generator-output verification only. config: trunk_branch: main - environments: [dev] + environments: [staging, prod] builds: - name: app workflow: build.yaml @@ -35,3 +40,4 @@ steps: - "cascade-pr-preview-${{ github.event.pull_request.number }}" - "preview:" - "Plan Preview" + - "--environment staging" diff --git a/internal/generate/pr_preview.go b/internal/generate/pr_preview.go index 5626d766..edb2adf9 100644 --- a/internal/generate/pr_preview.go +++ b/internal/generate/pr_preview.go @@ -158,6 +158,13 @@ func (g *PRPreviewGenerator) writeDetectStep(sb *strings.Builder) { // hard-coded, so no release is cut, no state is written, and no deploy is // triggered. orchestrate setup only reports the head SHA, the version that would // be cut, and which builds/deploys this merge would run. +// +// When the manifest declares environments the step targets the first (lowest) +// environment with --environment, mirroring how the orchestrate workflow +// defaults its setup environment. orchestrate setup resolves the version against +// that environment, so an empty value would fail with `environment "" not +// found`. A manifest with no environments omits the flag and runs the +// no-environment version calculation. func (g *PRPreviewGenerator) writeDeployDryRunStep(sb *strings.Builder) { sb.WriteString(" - name: Compute plan (dry-run)\n") // pull_request.head.sha is attacker-influenceable on a fork PR; route it @@ -169,6 +176,11 @@ func (g *PRPreviewGenerator) writeDeployDryRunStep(sb *strings.Builder) { sb.WriteString(" # so no release is cut, no state is written, and no deploy is triggered.\n") sb.WriteString(" cascade --dry-run orchestrate setup \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.config.GetManifestFile()) + if len(g.config.Environments) > 0 { + // Target the first environment so version calculation resolves; the + // preview is read-only regardless of which environment it reports on. + fmt.Fprintf(sb, " --environment %s \\\n", g.config.Environments[0]) + } sb.WriteString(" --sha \"$HEAD_SHA\" \\\n") sb.WriteString(" > cascade-plan.json\n") sb.WriteString("\n") diff --git a/internal/generate/pr_preview_test.go b/internal/generate/pr_preview_test.go index 586c3b45..23d2e869 100644 --- a/internal/generate/pr_preview_test.go +++ b/internal/generate/pr_preview_test.go @@ -49,6 +49,46 @@ func TestPRPreviewGenerator_PRFieldsAreNotInterpolatedIntoRun(t *testing.T) { assert.Contains(t, content, "HEAD_SHA: ${{ github.event.pull_request.head.sha }}") } +// TestPRPreviewGenerator_DryRunResolvesEnvironment asserts that the plan +// dry-run step targets a concrete environment so version calculation succeeds. +// +// orchestrate setup looks the --environment value up in the manifest's +// environments list; an empty value resolves to no match and fails with +// `environment "" not found`. For a manifest that declares environments the +// preview must pass --environment with the first (lowest) environment, mirroring +// how the orchestrate workflow defaults its setup environment. For a manifest +// with no environments the flag must be omitted entirely. +func TestPRPreviewGenerator_DryRunResolvesEnvironment(t *testing.T) { + t.Run("multi-env passes first environment", func(t *testing.T) { + cfg := prPreviewConfig(false) + cfg.Environments = []string{"staging", "prod"} + + gen := NewPRPreviewGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + planBody := stepRunBody(t, content, "Compute plan (dry-run)") + assert.Contains(t, planBody, "--environment staging", + "plan dry-run must target the first environment so version calc succeeds") + assert.NotContains(t, planBody, "--environment \"\"", + "plan dry-run must never pass an empty environment") + }) + + t.Run("no-env omits the environment flag", func(t *testing.T) { + cfg := prPreviewConfig(false) + cfg.Environments = nil + + gen := NewPRPreviewGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + planBody := stepRunBody(t, content, "Compute plan (dry-run)") + assert.NotContains(t, planBody, "--environment", + "no-env manifest must not pass --environment") + assert.Contains(t, planBody, "cascade --dry-run orchestrate setup") + }) +} + func TestPRPreviewGenerator_Disabled(t *testing.T) { // nil pr_preview gen := NewPRPreviewGenerator(&config.TrunkConfig{TrunkBranch: "main"}, "")