diff --git a/docs/src/content/docs/callback-contract.md b/docs/src/content/docs/callback-contract.md index 1128bc8..ba82371 100644 --- a/docs/src/content/docs/callback-contract.md +++ b/docs/src/content/docs/callback-contract.md @@ -471,16 +471,43 @@ ci: ## Environment Protection -Use GitHub environment protection for approval gates: +Use GitHub Environment protection for approval gates. Where you declare the +`environment:` key depends on whether the deploy is an external reusable +workflow or an inline `run:` callback, because GitHub Actions only allows a +job-level `environment:` key on a steps job, never on a job that calls a +reusable workflow with `uses:`. + +### External reusable-workflow deploys (`workflow:`) + +For a deploy backed by an external reusable workflow, declare `environment:` on +the job **inside your reusable workflow**. cascade passes the target environment +name to that workflow as the `environment` input, so wire it through: ```yaml +# your reusable deploy workflow jobs: deploy: runs-on: ubuntu-latest - environment: ${{ inputs.environment }} + environment: ${{ inputs.environment }} # protection lives here + steps: + - run: ./deploy.sh ``` -Configure in GitHub: **Settings -> Environments -> Add required reviewers**. +cascade cannot set `environment:` on the caller job it generates: GitHub Actions +rejects a workflow that puts `environment:` on a `uses:` job. cascade therefore +emits only the `with: environment:` input on the caller and relies on your +reusable workflow to apply the protection rules. cascade prints a generate-time +note when `gha_environment` is configured for an environment whose deploys are +external reusable workflows, reminding you to declare `environment:` inside the +reusable workflow. + +### Inline `run:` deploys + +For an inline `run:` deploy, cascade owns the job and emits the job-level +`environment:` key directly (resolved from `gha_environment` when configured), +so GitHub Environment protection applies without any extra wiring. + +Configure protection in GitHub: **Settings -> Environments -> Add required reviewers**. ## Dry Run Handling diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index f0500c7..ebac402 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -355,7 +355,11 @@ permissions: packages: write # Optional: only if your callbacks publish to GHCR ``` -For environment protection on deploys, set the environment in your callback: +For environment protection on an external reusable-workflow deploy, set the +`environment:` key on the job inside your callback. cascade passes the target +environment name as the `environment` input and cannot set `environment:` on the +caller job it generates, because GitHub Actions disallows that key on a `uses:` +job: ```yaml jobs: @@ -364,6 +368,9 @@ jobs: environment: ${{ inputs.environment }} # GitHub enforces approvals ``` +For an inline `run:` deploy, cascade owns the job and emits the job-level +`environment:` key for you when `gha_environment` is configured. + ## Concurrency Control Each workflow uses concurrency groups to prevent conflicts: diff --git a/internal/generate/env_gate_reusable_test.go b/internal/generate/env_gate_reusable_test.go new file mode 100644 index 0000000..dd79b2c --- /dev/null +++ b/internal/generate/env_gate_reusable_test.go @@ -0,0 +1,357 @@ +package generate + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// deployReusableStub is a minimal valid workflow_call reusable workflow that +// declares exactly the inputs the orchestrate/promote generators thread to a +// reusable deploy workflow (environment, sha, image_tag), so actionlint can +// validate the caller's with: block under full strictness without flagging +// undeclared inputs that are unrelated to the environment-on-uses regression. +const deployReusableStub = `name: Stub Deploy +on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + image_tag: + required: false + type: string + target_env: + required: false + type: string + dry_run: + required: false + type: string +jobs: + stub: + runs-on: ubuntu-latest + steps: + - run: 'true' +` + +// writeDeployReusableStub writes deployReusableStub at every local +// reusable-workflow reference (uses: ./...) found in the generated content, so +// actionlint can resolve each call site against a workflow that declares the +// inputs cascade passes. +func writeDeployReusableStub(t *testing.T, root, content string) { + t.Helper() + + const marker = "uses: ./" + seen := make(map[string]struct{}) + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + idx := strings.Index(trimmed, marker) + if idx < 0 { + continue + } + ref := strings.Fields(trimmed[idx+len("uses: "):])[0] + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + rel := strings.TrimPrefix(ref, "./") + stubPath := filepath.Join(root, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(stubPath), 0755)) + require.NoError(t, os.WriteFile(stubPath, []byte(deployReusableStub), 0644)) + } +} + +// hasJobLevelEnvironment reports whether the given job block contains a +// job-level environment: key. The job-level key sits at exactly 4-space +// indentation (" environment:"); reusable-workflow with: inputs sit at 6 +// spaces (" environment:") and must not count. +func hasJobLevelEnvironment(block string) bool { + for _, line := range strings.Split(block, "\n") { + if strings.HasPrefix(line, " environment:") && !strings.HasPrefix(line, " environment:") { + return true + } + } + return false +} + +// TestEnvGate_Orchestrate_ExternalDeploy_NoJobLevelEnvironment guards issue +// #137: when gha_environment is configured and a deploy uses an external +// reusable workflow (workflow:), the orchestrate caller job must NOT carry a +// job-level environment: key (GHA forbids it on a uses: job). The environment +// name is still passed via the with: environment: input. +func TestEnvGate_Orchestrate_ExternalDeploy_NoJobLevelEnvironment(t *testing.T) { + tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"staging", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + gen := NewGenerator(cfg, tmpDir) + result, err := gen.Generate() + require.NoError(t, err) + + block := jobBlock(t, result, "deploy-app") + require.NotEmpty(t, block, "deploy-app job not found") + + assert.Contains(t, block, "uses:", "external deploy must call the reusable workflow via uses:") + assert.False(t, hasJobLevelEnvironment(block), + "external (uses:) deploy job must NOT carry a job-level environment: key; block:\n%s", block) +} + +// TestEnvGate_Orchestrate_InlineDeploy_KeepsJobLevelEnvironment asserts that an +// inline run: deploy (a cascade-owned steps job) still carries a job-level +// environment: key, which is valid on a steps job and provides GitHub +// Environment protection. +func TestEnvGate_Orchestrate_InlineDeploy_KeepsJobLevelEnvironment(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"staging", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Run: "echo deploying", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + gen := NewGenerator(cfg, tmpDir) + result, err := gen.Generate() + require.NoError(t, err) + + block := jobBlock(t, result, "deploy-app") + require.NotEmpty(t, block, "deploy-app job not found") + + assert.NotContains(t, block, "uses:", "inline deploy must be a steps job, not a uses: caller") + assert.True(t, hasJobLevelEnvironment(block), + "inline (run:) deploy job must carry a job-level environment: key; block:\n%s", block) +} + +// TestEnvGate_Promote_ExternalSingleDeploy_NoJobLevelEnvironment guards #137 for +// the promote single-deploy path: an external deploy caller job must not carry +// a job-level environment: key. +func TestEnvGate_Promote_ExternalSingleDeploy_NoJobLevelEnvironment(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + gen := NewPromoteGenerator(cfg, "") + result, err := gen.Generate() + require.NoError(t, err) + + block := jobBlock(t, result, "deploy-svc") + require.NotEmpty(t, block, "deploy-svc job not found") + + assert.Contains(t, block, "uses:", "external deploy must call the reusable workflow via uses:") + assert.False(t, hasJobLevelEnvironment(block), + "external (uses:) promote deploy job must NOT carry a job-level environment: key; block:\n%s", block) + // The environment name is still threaded as a with: input. + assert.Contains(t, block, " environment: ${{ needs.preflight.outputs.target_env }}", + "promote external deploy must still pass environment as a with: input") +} + +// TestEnvGate_Promote_ExternalProdDeploy_NoJobLevelEnvironment guards #137 for +// the promote prod-deploy path: the deploy--prod external caller job must not +// carry a job-level environment: key. +func TestEnvGate_Promote_ExternalProdDeploy_NoJobLevelEnvironment(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + gen := NewPromoteGenerator(cfg, "") + result, err := gen.Generate() + require.NoError(t, err) + + block := jobBlock(t, result, "deploy-svc-prod") + require.NotEmpty(t, block, "deploy-svc-prod job not found") + + assert.Contains(t, block, "uses:", "external prod deploy must call the reusable workflow via uses:") + assert.False(t, hasJobLevelEnvironment(block), + "external (uses:) prod deploy job must NOT carry a job-level environment: key; block:\n%s", block) + // The environment name is still threaded as a with: input. + assert.Contains(t, block, " environment: prod", + "promote external prod deploy must still pass environment as a with: input") +} + +// TestEnvGate_Promote_InlineProdDeploy_KeepsJobLevelEnvironment asserts that an +// inline prod deploy still carries a job-level environment: key (valid on a +// steps job). +func TestEnvGate_Promote_InlineProdDeploy_KeepsJobLevelEnvironment(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + gen := NewPromoteGenerator(cfg, "") + result, err := gen.Generate() + require.NoError(t, err) + + block := jobBlock(t, result, "deploy-svc-prod") + require.NotEmpty(t, block, "deploy-svc-prod job not found") + + assert.NotContains(t, block, "uses:", "inline prod deploy must be a steps job") + assert.True(t, hasJobLevelEnvironment(block), + "inline (run:) prod deploy job must carry a job-level environment: key; block:\n%s", block) +} + +// TestEnvGate_Warning_ExternalDeployWithGHAEnvironment asserts that a +// generate-time warning fires when gha_environment is configured for an +// environment whose deploys are external reusable workflows, since cascade +// cannot set environment: on a uses: caller job. +func TestEnvGate_Warning_ExternalDeployWithGHAEnvironment(t *testing.T) { + tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"staging", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + gen := NewGenerator(cfg, tmpDir) + warnings := gen.Validate() + + found := false + for _, w := range warnings { + if strings.Contains(w, "reusable workflow") && strings.Contains(w, "environment") { + found = true + break + } + } + assert.True(t, found, + "expected a warning about gha_environment with external reusable-workflow deploys, got: %v", warnings) +} + +// TestEnvGate_Warning_InlineDeployWithGHAEnvironment asserts that the warning +// does NOT fire when the deploy is inline run: (environment: is valid there). +func TestEnvGate_Warning_InlineDeployWithGHAEnvironment(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"staging", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Run: "echo deploying", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + gen := NewGenerator(cfg, tmpDir) + warnings := gen.Validate() + + for _, w := range warnings { + if strings.Contains(w, "reusable workflow") && strings.Contains(w, "environment") { + t.Errorf("warning should not fire for inline run: deploy, got: %q", w) + } + } +} + +// TestEnvGate_Actionlint_ExternalDeployWithGHAEnvironment runs actionlint over +// the generated orchestrate and promote workflows for the external-deploy + +// gha_environment case and asserts there is no "environment is not available" +// error. Skipped when actionlint is not installed so the suite stays hermetic. +func TestEnvGate_Actionlint_ExternalDeployWithGHAEnvironment(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "staging", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + }, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {GHAEnvironment: "production"}, + }, + } + + orchestrate, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + promote, err := NewPromoteGenerator(cfg, "").Generate() + require.NoError(t, err) + + for _, tc := range []struct { + file, content string + }{ + {"orchestrate.yaml", orchestrate}, + {"promote.yaml", promote}, + } { + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + wfPath := filepath.Join(wfDir, tc.file) + require.NoError(t, os.WriteFile(wfPath, []byte(tc.content), 0644)) + + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + + // Stub the referenced reusable deploy workflow declaring exactly the + // inputs the generator threads to it (environment, sha, image_tag) so + // actionlint resolves the with: block honestly rather than flagging + // undeclared inputs unrelated to this regression. + writeDeployReusableStub(t, dir, tc.content) + + // Disable shellcheck: the inline run: bodies trip SC2129 style nits that + // are orthogonal to this regression and exercised elsewhere. + cmd := exec.Command(bin, "-shellcheck=", wfPath) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + // The regression: GitHub Actions forbids a job-level environment: key on a + // reusable-workflow caller job. actionlint phrases this as + // `"environment" is not available`. The fix removes the offending key. + assert.NotContains(t, string(out), "\"environment\" is not available", + "%s must not trigger the reusable-workflow environment error:\n%s", tc.file, string(out)) + assert.NoError(t, runErr, "actionlint reported issues for %s:\n%s", tc.file, string(out)) + } +} diff --git a/internal/generate/env_gates_test.go b/internal/generate/env_gates_test.go index 4bed3da..6cf65a9 100644 --- a/internal/generate/env_gates_test.go +++ b/internal/generate/env_gates_test.go @@ -14,22 +14,18 @@ import ( // ---- orchestrate generator tests ---- // TestEnvGates_Orchestrate_DeployJob_WithGHAEnvironment asserts that when -// environment_config carries a gha_environment for an env, the generated -// orchestrate deploy job contains a job-level environment: key. +// environment_config carries a gha_environment for an env and the deploy is an +// INLINE run: job (a cascade-owned steps job), the generated orchestrate deploy +// job carries a job-level environment: key. The job-level key is only valid on a +// steps job; the external (uses:) variant is covered in env_gate_reusable_test.go. func TestEnvGates_Orchestrate_DeployJob_WithGHAEnvironment(t *testing.T) { tmpDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) - require.NoError(t, os.WriteFile( - filepath.Join(tmpDir, ".github/workflows/deploy.yaml"), - []byte("on:\n workflow_call:\n"), - 0644, - )) cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, Deploys: []config.DeployConfig{ - {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ "prod": {GHAEnvironment: "production"}, @@ -43,8 +39,8 @@ func TestEnvGates_Orchestrate_DeployJob_WithGHAEnvironment(t *testing.T) { block := jobBlock(t, result, "deploy-svc") require.NotEmpty(t, block, "deploy-svc job not found in generated orchestrate workflow") - // Job-level environment: key must be present. - assert.Contains(t, block, "environment:", "deploy job must carry job-level environment: key when gha_environment is configured") + // Job-level environment: key must be present on an inline steps job. + assert.Contains(t, block, "environment:", "inline deploy job must carry job-level environment: key when gha_environment is configured") // It should use the dynamic input expression (env is chosen at runtime). assert.Contains(t, block, "github.event.inputs.environment", "orchestrate environment: key must use the workflow input expression") } @@ -130,14 +126,16 @@ func TestEnvGates_Orchestrate_BuildJob_NoEnvironmentKey(t *testing.T) { // ---- promote generator tests ---- // TestEnvGates_Promote_SingleDeployJob_WithGHAEnvironment asserts that the -// promote generator emits a job-level environment: on the single-mode deploy -// job when any environment has gha_environment configured. +// promote generator emits a job-level environment: on the single-mode INLINE +// run: deploy job when any environment has gha_environment configured. The +// job-level key is only valid on a steps job; the external (uses:) variant is +// covered in env_gate_reusable_test.go. func TestEnvGates_Promote_SingleDeployJob_WithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, Deploys: []config.DeployConfig{ - {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ "prod": {GHAEnvironment: "production"}, @@ -151,7 +149,7 @@ func TestEnvGates_Promote_SingleDeployJob_WithGHAEnvironment(t *testing.T) { block := jobBlock(t, result, "deploy-svc") require.NotEmpty(t, block, "deploy-svc job not found in generated promote workflow") - assert.Contains(t, block, "environment:", "promote deploy job must carry job-level environment: key when gha_environment is configured") + assert.Contains(t, block, "environment:", "inline promote deploy job must carry job-level environment: key when gha_environment is configured") assert.Contains(t, block, "needs.preflight.outputs.target_env", "promote deploy environment: key must reference preflight's target_env output") } @@ -186,13 +184,15 @@ func TestEnvGates_Promote_SingleDeployJob_WithoutGHAEnvironment(t *testing.T) { // TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment asserts that the // promote generator emits a job-level environment: on the prod deploy job // (deploy--prod) using the static gha_environment name when the final env -// has gha_environment configured. +// has gha_environment configured AND the deploy is an INLINE run: job. The +// job-level key is only valid on a steps job; the external (uses:) variant is +// covered in env_gate_reusable_test.go. func TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, Deploys: []config.DeployConfig{ - {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ "prod": {GHAEnvironment: "production"}, @@ -207,7 +207,7 @@ func TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment(t *testing.T) { require.NotEmpty(t, block, "deploy-svc-prod job not found in generated promote workflow") // The prod deploy job must carry the static resolved GitHub Environment name. - assert.Contains(t, block, "environment: production", "prod deploy job must carry the gha_environment name statically") + assert.Contains(t, block, "environment: production", "inline prod deploy job must carry the gha_environment name statically") } // TestEnvGates_Promote_ProdDeployJob_WithoutGHAEnvironment asserts that the @@ -247,7 +247,9 @@ func TestEnvGates_Promote_ProdDeployJob_OnlyFinalEnvGated(t *testing.T) { TrunkBranch: "main", Environments: []string{"dev", "staging", "prod"}, Deploys: []config.DeployConfig{ - {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, + // Inline run: deploy so the single job-level environment: key is valid; + // the gating-by-env behaviour under test is independent of deploy kind. + {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ // Only the intermediate env has gha_environment; prod does not. diff --git a/internal/generate/generator.go b/internal/generate/generator.go index cb939c8..2543715 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -296,9 +296,50 @@ func (g *Generator) Validate() []string { // emitted as dead literals; the operator likely intended passthrough. g.warnings = append(g.warnings, g.unwrappedExpressionWarnings()...) + // Warn when gha_environment is configured but the deploys are external + // reusable workflows. GitHub Actions forbids a job-level environment: key on + // a reusable-workflow caller job (jobs..uses), so cascade cannot wire the + // GitHub Environment protection rules onto the caller. cascade still passes + // the environment name via the with: environment input; the protection rules + // must be declared on the internal job inside the reusable workflow itself. + g.warnings = append(g.warnings, g.externalDeployEnvironmentWarnings()...) + return g.warnings } +// externalDeployEnvironmentWarnings returns a warning when gha_environment is +// configured for any environment while one or more deploys are external +// reusable workflows. GitHub Actions forbids a job-level environment: key on a +// reusable-workflow caller job, so cascade cannot apply GitHub Environment +// protection to those deploys from the caller side. The environment name is +// still passed via the with: environment input; the protection must be declared +// inside the reusable workflow's own job. +func (g *Generator) externalDeployEnvironmentWarnings() []string { + if !anyEnvHasGHAConfig(g.config) { + return nil + } + + externalDeploys := make([]string, 0, len(g.config.Deploys)) + for _, d := range g.config.Deploys { + if d.Run == "" { + externalDeploys = append(externalDeploys, d.Name) + } + } + if len(externalDeploys) == 0 { + return nil + } + + return []string{fmt.Sprintf( + "Note: gha_environment is configured but deploy(s) %s use an external "+ + "reusable workflow. GitHub Actions disallows a job-level environment: key "+ + "on a reusable-workflow caller job, so cascade cannot apply GitHub "+ + "Environment protection from the caller. cascade passes the environment "+ + "name as the reusable workflow's 'environment' input; declare "+ + "environment: on the job inside your reusable workflow to enforce "+ + "protection rules.", + strings.Join(externalDeploys, ", "))} +} + // unwrappedExpressionWarnings scans all callback inputs/env_inputs for literal // values that resemble an unwrapped expression (bare context.path) and returns // a warning for each, so operators catch a forgotten ${{ }} wrapper. @@ -793,7 +834,14 @@ func (g *Generator) writeCallbackJob(sb *strings.Builder, info CallbackInfo, wor // workflow_dispatch input, so we emit an expression that resolves to the // cascade environment name. When gha_environment differs from the cascade // env name, users should align their GitHub Environment names accordingly. - if info.Type == config.CallbackTypeDeploy && len(g.config.Environments) > 0 && anyEnvHasGHAConfig(g.config) { + // + // The job-level environment: key is only valid on a steps job (an inline + // run: deploy). GitHub Actions forbids it on a reusable-workflow caller job + // (one that uses jobs..uses), so it is gated on info.Run being set. For + // external (uses:) deploys the environment name is threaded via the with: + // environment input instead, and GitHub Environment protection must be + // declared inside the reusable workflow's own job. + if info.Type == config.CallbackTypeDeploy && info.Run != "" && len(g.config.Environments) > 0 && anyEnvHasGHAConfig(g.config) { defaultEnv := g.config.Environments[0] fmt.Fprintf(sb, " environment: ${{ github.event.inputs.environment || '%s' }}\n", defaultEnv) } diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 9a9a80e..5f6dd59 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -838,12 +838,11 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { } else { fmt.Fprintf(sb, " if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.deploys_to_run), '%s') }}\n", d.Name) } - // environment: wires the job to a GitHub Environment so that the - // environment's protection rules apply when gha_environment is configured - // for any env. The target env is resolved at runtime by preflight. - if anyEnvHasGHAConfig(g.config) { - sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") - } + // This branch only runs for external (uses:) deploys, so no job-level + // environment: key is emitted: GitHub Actions forbids it on a + // reusable-workflow caller job. The environment name is threaded via + // the with: environment input below, and GitHub Environment protection + // must be declared inside the reusable workflow's own job. fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) sb.WriteString(" with:\n") sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") @@ -876,10 +875,17 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { // environment: The prod deploy job always targets a single known env // (the final environment in the pipeline), so we can resolve the GitHub // Environment name statically from gha_environment when configured. - if ec, ok := g.config.EnvironmentConfig[finalEnv]; ok && ec.GHAEnvironment != "" { - fmt.Fprintf(sb, " environment: %s\n", ec.GHAEnvironment) - } + // + // The job-level environment: key is only valid on a steps job (an inline + // run: deploy). GitHub Actions forbids it on a reusable-workflow caller + // job, so it is gated on d.Run being set. For external (uses:) deploys + // the environment name is threaded via the with: environment input below, + // and GitHub Environment protection must be declared inside the reusable + // workflow's own job. if d.Run != "" { + if ec, ok := g.config.EnvironmentConfig[finalEnv]; ok && ec.GHAEnvironment != "" { + fmt.Fprintf(sb, " environment: %s\n", ec.GHAEnvironment) + } g.writeInlineDeployBody(sb, d, finalEnv, "${{ needs.preflight.outputs.prod_sha }}",