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
19 changes: 13 additions & 6 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
67 changes: 63 additions & 4 deletions internal/generate/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 }}")

Expand Down
11 changes: 7 additions & 4 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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()
Expand Down
Loading