diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index 2b053384..11cf892d 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -151,6 +151,14 @@ func (h *Harness) StageRepoFromConfig(ctx context.Context, config Config) error files[p] = generatePublishStubWorkflow(scenarioTag) } } + // A custom changelog workflow is a reusable workflow invoked as a + // job-level uses:. Stub it so the generated changelog job resolves and + // exposes a changelog output for the release step to consume. + if wf, ok := config.Changelog["workflow"].(string); ok && wf != "" { + if p := normalizeCallbackStubPath(wf); p != "" { + files[p] = generateChangelogStubWorkflow(scenarioTag) + } + } // Create mock setup-cli action that installs CLI from repo // The generated workflows reference stablekernel/cascade/.github/actions/setup-cli @@ -355,6 +363,43 @@ jobs: `, displayName, name, name) } +// generateChangelogStubWorkflow returns a reusable workflow_call stub for a +// custom changelog workflow. It declares the inputs the generator threads +// (changelog_base_sha, head_sha, repo) and a changelog output so the generated +// changelog job and the downstream release step resolve correctly. +func generateChangelogStubWorkflow(scenarioTag string) string { + displayName := "Changelog" + if scenarioTag != "" { + displayName = fmt.Sprintf("Changelog [scenario-%s]", scenarioTag) + } + return fmt.Sprintf(`name: %s +on: + workflow_call: + inputs: + changelog_base_sha: + type: string + required: false + head_sha: + type: string + required: false + repo: + type: string + required: false + outputs: + changelog: + description: Generated changelog markdown + value: ${{ jobs.changelog.outputs.changelog }} +jobs: + changelog: + runs-on: ubuntu-latest + outputs: + changelog: ${{ steps.gen.outputs.changelog }} + steps: + - id: gen + run: echo "changelog=- custom changelog entry" >> "$GITHUB_OUTPUT" +`, displayName) +} + // GenerateWorkflows generates GitHub Actions workflows from cicd-config.yaml func (h *Harness) GenerateWorkflows(ctx context.Context) error { if h.repo == nil { diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index dc3329fe..d354db0c 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -34,6 +34,11 @@ type Config struct { Builds []BuildConfig `yaml:"builds"` Deploys []DeployConfig `yaml:"deploys"` Publish *PublishConfig `yaml:"publish,omitempty"` + // Changelog carries the changelog block (custom workflow, contributors) + // through to the generated manifest untouched. A generic map keeps the + // harness decoupled from the generator's ChangelogConfig shape while + // preserving every key across the marshal round-trip. + Changelog map[string]any `yaml:"changelog,omitempty"` // DispatchInputs carries operator-facing workflow_dispatch inputs through to // the generated manifest untouched. A generic map (rather than a typed // struct) is used so the harness stays decoupled from the generator's diff --git a/e2e/scenarios/19-custom-changelog.yaml b/e2e/scenarios/19-custom-changelog.yaml new file mode 100644 index 00000000..844dc8ec --- /dev/null +++ b/e2e/scenarios/19-custom-changelog.yaml @@ -0,0 +1,47 @@ +name: "Custom Changelog Workflow" +description: "Repository with a custom changelog reusable workflow - the changelog runs as its own job and feeds the release" + +config: + trunk_branch: main + environments: [] + changelog: + workflow: .github/workflows/cl.yaml + contributors: true + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: [] + +steps: + # Push first commit to trunk. The orchestrate run invokes the custom changelog + # reusable workflow as a dedicated job (needs: [setup]); finalize depends on it + # and the release step reads needs.changelog.outputs.changelog. The previous + # behavior emitted the reusable workflow as a step uses:, which GitHub Actions + # rejects at parse time, so this scenario only passes with the F7 fix. + - name: "Initial feature commit" + action: commit + commit: + message: "feat: add initial feature" + files: + src/app.go: | + package main + func main() { + println("Hello v0.1.0") + } + + - name: "Orchestrate after first commit" + action: orchestrate + expect: + state: + prerelease: + sha: commit1 + version: "v0.1.0-rc.0" + jobs: + build-app: success + releases: + - tag: "v0.1.0-rc.0" + prerelease: true + draft: true + tags: + exist: ["v0.1.0-rc.0"] diff --git a/internal/generate/custom_changelog_test.go b/internal/generate/custom_changelog_test.go new file mode 100644 index 00000000..e5b7d9cb --- /dev/null +++ b/internal/generate/custom_changelog_test.go @@ -0,0 +1,168 @@ +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" +) + +// changelogReusableStub is a minimal valid workflow_call reusable workflow that +// declares exactly the inputs the generator threads to a custom changelog +// workflow (changelog_base_sha, head_sha, repo) and the changelog output the +// finalize/release step consumes via needs.changelog.outputs.changelog. This +// lets actionlint resolve the caller job's with: block and the downstream +// needs.changelog.outputs.changelog reference under full strictness. +const changelogReusableStub = `name: Stub Changelog +on: + workflow_call: + inputs: + changelog_base_sha: + required: false + type: string + head_sha: + required: false + type: string + repo: + required: false + type: string + outputs: + changelog: + description: Generated changelog markdown + value: ${{ jobs.changelog.outputs.changelog }} +jobs: + changelog: + runs-on: ubuntu-latest + outputs: + changelog: ${{ steps.gen.outputs.changelog }} + steps: + - id: gen + run: echo "changelog=stub" >> "$GITHUB_OUTPUT" +` + +// callbackReusableStub is a permissive workflow_call target for the build/deploy +// callbacks referenced by the generated orchestrate workflow, declaring the +// inputs cascade threads to callbacks so actionlint does not flag them. +const callbackReusableStub = `name: Stub Callback +on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + target_env: + required: false + type: string + dry_run: + required: false + type: string + outputs: + image_tag: + value: stub +jobs: + stub: + runs-on: ubuntu-latest + outputs: + image_tag: stub + steps: + - run: 'true' +` + +// writeCustomChangelogStubs writes changelogReusableStub at every local +// reusable-workflow reference (uses: ./...) found in the generated content and a +// minimal build stub for any other local reusable-workflow call, so actionlint +// can resolve each call site honestly. +func writeCustomChangelogStubs(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)) + + stub := changelogReusableStub + if !strings.Contains(rel, "custom-changelog") { + // Any other local reusable workflow (e.g. the build callback) must + // declare the inputs the generator threads to it so actionlint + // resolves the caller with: block honestly. + stub = callbackReusableStub + } + require.NoError(t, os.WriteFile(stubPath, []byte(stub), 0644)) + } +} + +// TestCustomChangelog_Actionlint generates the orchestrate workflow for a +// custom-changelog repo and runs actionlint over it. It proves both F7 hazards +// are gone: 7a (a reusable workflow emitted as a step uses: is rejected at parse +// time) and 7b (an input value referencing a non-existent setup output). The +// custom changelog is now a job-level uses: with inputs keyed to the setup +// job's real outputs, so actionlint reports no issues. Skipped when actionlint +// is not installed so the suite stays hermetic. +func TestCustomChangelog_Actionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + tmpDir := t.TempDir() + wfDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + // The generator reads the build and changelog reusable workflows to + // discover their inputs/outputs at generation time. + require.NoError(t, os.WriteFile(filepath.Join(wfDir, "build.yaml"), + []byte("on:\n workflow_call:\n outputs:\n image_tag:\n value: stub\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(wfDir, "custom-changelog.yaml"), + []byte(changelogReusableStub), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Changelog: &config.ChangelogConfig{Workflow: ".github/workflows/custom-changelog.yaml", Contributors: true}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + + content, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + // Run actionlint against the generated workflow in an isolated git repo so + // local reusable-workflow refs (uses: ./...) resolve against the repo root. + dir := t.TempDir() + lintDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(lintDir, 0755)) + wfPath := filepath.Join(lintDir, "orchestrate.yaml") + require.NoError(t, os.WriteFile(wfPath, []byte(content), 0644)) + + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + + writeCustomChangelogStubs(t, dir, content) + + // Disable shellcheck: inline run: bodies trip style nits orthogonal to F7. + cmd := exec.Command(bin, "-shellcheck=", wfPath) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues for the custom-changelog workflow:\n%s", string(out)) +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 95ed1fa7..c424d37f 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -701,9 +701,24 @@ func (g *Generator) writeJobs(sb *strings.Builder) { } } + // A custom changelog is a reusable workflow and must run as its own + // job-level `uses:` call (it cannot be a step). Emit it before finalize so + // finalize can consume its output via needs.changelog.outputs.changelog. + if g.changelogJobEnabled() { + g.writeChangelogJob(sb) + } + g.writeFinalizeJob(sb, sorted) } +// changelogJobEnabled reports whether a dedicated custom-changelog job should be +// emitted. This mirrors the condition under which the finalize job would +// otherwise produce a changelog: release and changelog enabled, with a custom +// reusable workflow configured. +func (g *Generator) changelogJobEnabled() bool { + return g.config.ReleaseEnabled() && g.config.ChangelogEnabled() && g.config.HasCustomChangelog() +} + func (g *Generator) writeSetupJob(sb *strings.Builder) { sb.WriteString(" setup:\n") sb.WriteString(" name: Setup\n") @@ -1364,6 +1379,11 @@ func (g *Generator) writeFinalizeJob(sb *strings.Builder, sorted []string) { allJobs = append(allJobs, fmt.Sprintf("%s-retry-%d", jobID, i)) } } + // The custom changelog runs as its own job; finalize consumes its output, + // so it must be in finalize's needs:. + if g.changelogJobEnabled() { + allJobs = append(allJobs, changelogJobID) + } sb.WriteString(" finalize:\n") sb.WriteString(" name: Finalize\n") @@ -1739,17 +1759,17 @@ func failureOrCancelledCond(jobName string) string { return fmt.Sprintf("contains(fromJSON('[\"failure\", \"cancelled\"]'), needs.%s.result)", jobName) } +// writeChangelogStep emits the built-in changelog generation as a step inside +// the finalize job. The custom changelog path is NOT a step: a reusable +// workflow cannot be invoked as a step `uses:`, so it is hoisted into its own +// job (see writeChangelogJob) and this function does nothing for that case. func (g *Generator) writeChangelogStep(sb *strings.Builder) { if g.config.HasCustomChangelog() { - // Call custom changelog workflow - sb.WriteString(" - name: Generate Changelog (Custom)\n") - sb.WriteString(" id: changelog\n") - fmt.Fprintf(sb, " uses: %s\n", g.config.Changelog.Workflow) - sb.WriteString(" with:\n") - sb.WriteString(" base_sha: ${{ needs.setup.outputs.base_sha }}\n") - sb.WriteString(" head_sha: ${{ needs.setup.outputs.head_sha }}\n") - sb.WriteString(" repo: ${{ github.repository }}\n") - } else { + // Custom changelog is emitted as a dedicated job (writeChangelogJob), + // not as a step, because a reusable workflow is invalid as a step uses:. + return + } + { // Use built-in changelog generation sb.WriteString(" - name: Setup CLI\n") fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef()) @@ -1777,6 +1797,33 @@ func (g *Generator) writeChangelogStep(sb *strings.Builder) { } } +// changelogJobID is the job name used for the hoisted custom changelog job. +// The finalize job depends on it and the release step reads its `changelog` +// output via needs.changelog.outputs.changelog. +const changelogJobID = "changelog" + +// writeChangelogJob emits the custom changelog reusable workflow as a +// dedicated job-level `uses:` call. A reusable workflow cannot be invoked as a +// step `uses:`, so the custom changelog (config.Changelog.Workflow) is hoisted +// into its own job. The job depends on setup so it can read the SHAs the setup +// job exposes, and exposes the called workflow's `changelog` output for the +// finalize/release step to consume. +// +// This is only emitted when g.config.HasCustomChangelog() is true; the built-in +// changelog remains a step inside the finalize job (writeChangelogStep). +func (g *Generator) writeChangelogJob(sb *strings.Builder) { + fmt.Fprintf(sb, " %s:\n", changelogJobID) + sb.WriteString(" name: Changelog\n") + sb.WriteString(" needs: [setup]\n") + fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(g.config.Changelog.Workflow)) + sb.WriteString(" with:\n") + // Pass the base SHA from the output the setup job actually declares: + // changelog_base_sha (not base_sha, which does not exist). + sb.WriteString(" changelog_base_sha: ${{ needs.setup.outputs.changelog_base_sha }}\n") + sb.WriteString(" head_sha: ${{ needs.setup.outputs.head_sha }}\n") + sb.WriteString(" repo: ${{ github.repository }}\n") +} + func (g *Generator) writeReleaseStep(sb *strings.Builder) { sb.WriteString(" - name: Manage Release\n") fmt.Fprintf(sb, " uses: %s\n", g.getActionPath()) @@ -1815,7 +1862,13 @@ func (g *Generator) writeReleaseStep(sb *strings.Builder) { } sb.WriteString(" sha: ${{ needs.setup.outputs.head_sha }}\n") if g.config.ChangelogEnabled() { - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + if g.config.HasCustomChangelog() { + // Custom changelog runs as its own job; read its job output. + fmt.Fprintf(sb, " changelog: ${{ needs.%s.outputs.changelog }}\n", changelogJobID) + } else { + // Built-in changelog runs as a step in this job; read the step output. + sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + } } sb.WriteString(" previous_tag: ${{ needs.setup.outputs.previous_tag }}\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) diff --git a/internal/generate/generator_test.go b/internal/generate/generator_test.go index 2300aff3..7d9765d3 100644 --- a/internal/generate/generator_test.go +++ b/internal/generate/generator_test.go @@ -796,15 +796,34 @@ on: output, err := gen.Generate() require.NoError(t, err) - // Should reference custom changelog workflow + // A reusable workflow cannot be invoked as a step `uses:`; it must be a + // job-level `uses:`. The custom changelog is therefore hoisted into its own + // `changelog` job that calls the reusable workflow. assert.Contains(t, output, "custom-changelog.yaml", "Output should reference custom changelog workflow") - // Verify uses: directive with the custom workflow path - assert.Contains(t, output, "uses: .github/workflows/custom-changelog.yaml", "Should contain uses directive with custom workflow path") - // Verify input parameters are passed - assert.Contains(t, output, "base_sha: ${{ needs.setup.outputs.base_sha }}", "Should pass base_sha input parameter") + + // The custom changelog must NOT be emitted as a step inside finalize. + assert.NotContains(t, output, "Generate Changelog (Custom)", + "Custom changelog must be a job, not a step (a reusable workflow cannot be a step uses:)") + + // It must be a job-level `uses:` with a normalized (./-prefixed) path so + // actionlint resolves it as a reusable-workflow call. + assert.Contains(t, output, " changelog:\n", "Should emit a dedicated changelog job") + assert.Contains(t, output, "uses: ./.github/workflows/custom-changelog.yaml", + "Changelog job should call the reusable workflow via a normalized job-level uses:") + + // The changelog job depends on setup and passes the base SHA from the + // output the setup job actually declares: changelog_base_sha (not base_sha). + assert.Contains(t, output, "changelog_base_sha: ${{ needs.setup.outputs.changelog_base_sha }}", + "Changelog job should pass changelog_base_sha keyed to the real setup output") assert.Contains(t, output, "head_sha: ${{ needs.setup.outputs.head_sha }}", "Should pass head_sha input parameter") - // Setup CLI is now in setup job for version calculation, but should not be in finalize job changelog step - assert.Contains(t, output, "Generate Changelog (Custom)", "Should have custom changelog step in finalize job") + assert.NotContains(t, output, "needs.setup.outputs.base_sha", + "Setup job does not declare a base_sha output; that reference would be empty at runtime") + + // The release step must read the changelog from the job output, not a step. + assert.Contains(t, output, "changelog: ${{ needs.changelog.outputs.changelog }}", + "Release step should consume the changelog from the changelog job output") + assert.NotContains(t, output, "changelog: ${{ steps.changelog.outputs.changelog }}", + "Release step must not read a step output for the custom changelog case") } func TestGenerator_FinalizeJob_FrameworkManagedRelease(t *testing.T) {