diff --git a/internal/generate/generator.go b/internal/generate/generator.go index a60b89ce..cb939c85 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -646,24 +646,31 @@ func (g *Generator) writeSetupJob(sb *strings.Builder) { g.writeOwnedTimeout(sb, " ") sb.WriteString(" outputs:\n") - // All outputs come from the CLI setup command + // All outputs come from the CLI setup command. Output keys are normalized to + // underscores via OutputKey: GitHub Actions parses a hyphen in an expression + // as subtraction, so a hyphenated key would never match the consuming if:. for _, b := range g.config.Builds { - fmt.Fprintf(sb, " run_build_%s: ${{ steps.setup.outputs.run_build_%s }}\n", b.Name, b.Name) + key := config.OutputKey(b.Name) + fmt.Fprintf(sb, " run_build_%s: ${{ steps.setup.outputs.run_build_%s }}\n", key, key) } for _, d := range g.config.Deploys { - fmt.Fprintf(sb, " run_deploy_%s: ${{ steps.setup.outputs.run_deploy_%s }}\n", d.Name, d.Name) + key := config.OutputKey(d.Name) + fmt.Fprintf(sb, " run_deploy_%s: ${{ steps.setup.outputs.run_deploy_%s }}\n", key, key) } sb.WriteString(" head_sha: ${{ steps.setup.outputs.head_sha }}\n") sb.WriteString(" version: ${{ steps.setup.outputs.version }}\n") sb.WriteString(" previous_tag: ${{ steps.setup.outputs.previous_tag }}\n") sb.WriteString(" changelog_base_sha: ${{ steps.setup.outputs.changelog_base_sha }}\n") - // Per-deployable base SHAs for callbacks that need them + // Per-deployable base SHAs for callbacks that need them. Keys are normalized + // to underscores via OutputKey for the same GitHub Actions expression reason. for _, b := range g.config.Builds { - fmt.Fprintf(sb, " base_build_%s: ${{ steps.setup.outputs.base_build_%s }}\n", b.Name, b.Name) + key := config.OutputKey(b.Name) + fmt.Fprintf(sb, " base_build_%s: ${{ steps.setup.outputs.base_build_%s }}\n", key, key) } for _, d := range g.config.Deploys { - fmt.Fprintf(sb, " base_deploy_%s: ${{ steps.setup.outputs.base_deploy_%s }}\n", d.Name, d.Name) + key := config.OutputKey(d.Name) + fmt.Fprintf(sb, " base_deploy_%s: ${{ steps.setup.outputs.base_deploy_%s }}\n", key, key) } sb.WriteString(" steps:\n") diff --git a/internal/generate/generator_test.go b/internal/generate/generator_test.go index 51758581..cc9b18b2 100644 --- a/internal/generate/generator_test.go +++ b/internal/generate/generator_test.go @@ -877,12 +877,71 @@ on: assert.Contains(t, content, "cascade orchestrate setup") assert.Contains(t, content, "--gha-output") - // Outputs reference CLI outputs - assert.Contains(t, content, "run_deploy_app-deploy: ${{ steps.setup.outputs.run_deploy_app-deploy }}") + // Outputs reference CLI outputs. Output keys are normalized to underscores + // (GitHub Actions parses hyphens in expressions as subtraction). + assert.Contains(t, content, "run_deploy_app_deploy: ${{ steps.setup.outputs.run_deploy_app_deploy }}") assert.Contains(t, content, "run_deploy_cdk: ${{ steps.setup.outputs.run_deploy_cdk }}") assert.Contains(t, content, "run_deploy_notify: ${{ steps.setup.outputs.run_deploy_notify }}") } +// TestGenerator_HyphenatedNameOutputKeysConsistent guards against the silent +// skip described in #127: a hyphenated build/deploy name must produce the same +// underscore-normalized run_build_*/run_deploy_* identifier at every site (the +// setup job-level outputs passthrough and the consuming job if: condition). If +// any site emits a hyphen, GitHub Actions parses the expression as subtraction +// and the consuming job's if: never matches, silently skipping the work. +func TestGenerator_HyphenatedNameOutputKeysConsistent(t *testing.T) { + tmpDir := t.TempDir() + workflowDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowDir, 0755)) + + mockWorkflow := `name: Mock +on: + workflow_call: + outputs: + result: + value: test +` + require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "build.yaml"), []byte(mockWorkflow), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "deploy.yaml"), []byte(mockWorkflow), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + {Name: "shared-lib", Workflow: ".github/workflows/build.yaml", Triggers: []string{"libs/**"}}, + }, + Deploys: []config.DeployConfig{ + {Name: "web-api", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"api/**"}}, + }, + } + + gen := NewGenerator(cfg, tmpDir) + content, err := gen.Generate() + require.NoError(t, err) + + // (a) The job-level outputs passthrough uses the underscore identifier on + // BOTH the output key and the steps.setup.outputs.* reference. + assert.Contains(t, content, "run_build_shared_lib: ${{ steps.setup.outputs.run_build_shared_lib }}", + "build passthrough must use underscore output key and reference") + assert.Contains(t, content, "run_deploy_web_api: ${{ steps.setup.outputs.run_deploy_web_api }}", + "deploy passthrough must use underscore output key and reference") + + // (b) No hyphenated run_* output identifier appears anywhere. A hyphen in a + // GHA expression is subtraction, so these would silently break. + assert.NotContains(t, content, "run_build_shared-lib", + "hyphenated build output identifier breaks GHA expression parsing") + assert.NotContains(t, content, "run_deploy_web-api", + "hyphenated deploy output identifier breaks GHA expression parsing") + + // (c) The consuming job if: condition references the SAME underscore key the + // passthrough exposes (not an always-false hyphenated reference). + assert.Contains(t, content, "needs.setup.outputs.run_build_shared_lib == 'true'", + "build if: must reference the underscore-normalized key") + assert.Contains(t, content, "needs.setup.outputs.run_deploy_web_api == 'true'", + "deploy if: must reference the underscore-normalized key") +} + func TestGenerator_BuildLinkedDeployCondition(t *testing.T) { tmpDir := t.TempDir() workflowDir := filepath.Join(tmpDir, ".github", "workflows") @@ -1004,8 +1063,8 @@ on: assert.Contains(t, content, "cascade orchestrate setup") assert.Contains(t, content, "--gha-output") - // Outputs reference CLI outputs - assert.Contains(t, content, "run_deploy_app-deploy: ${{ steps.setup.outputs.run_deploy_app-deploy }}") + // Outputs reference CLI outputs. Output keys are normalized to underscores. + assert.Contains(t, content, "run_deploy_app_deploy: ${{ steps.setup.outputs.run_deploy_app_deploy }}") assert.Contains(t, content, "run_deploy_cdk: ${{ steps.setup.outputs.run_deploy_cdk }}") assert.Contains(t, content, "run_deploy_notify: ${{ steps.setup.outputs.run_deploy_notify }}") diff --git a/internal/output/output.go b/internal/output/output.go index cf1f522f..1fc2acd7 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/stablekernel/cascade/internal/config" "github.com/stablekernel/cascade/internal/ghaoutput" "github.com/stablekernel/cascade/internal/globals" ) @@ -141,19 +142,21 @@ func (r *SetupResult) WriteGHAOutput() error { w.Set("previous_tag", r.PreviousTag) w.Set("changelog_base_sha", r.ChangelogBaseSHA) - // Build decisions + // Build decisions. Output keys are normalized to underscores via OutputKey + // so they match the run_build_* identifiers the generated workflow consumes; + // GitHub Actions parses a hyphen in an expression as subtraction. for name, run := range r.RunBuilds { - w.SetBool(fmt.Sprintf("run_build_%s", name), run) + w.SetBool(fmt.Sprintf("run_build_%s", config.OutputKey(name)), run) } // Deploy decisions for name, run := range r.RunDeploys { - w.Set(fmt.Sprintf("run_deploy_%s", name), run) + w.Set(fmt.Sprintf("run_deploy_%s", config.OutputKey(name)), run) } // Base SHAs for per-deployable change detection for name, sha := range r.BaseSHAs { - w.Set(fmt.Sprintf("base_%s", name), sha) + w.Set(fmt.Sprintf("base_%s", config.OutputKey(name)), sha) } return w.Flush()