diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index b664e1de..3c241f23 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -263,23 +263,6 @@ jobs: delete_tag: ${{ steps.release-data.outputs.rc_version }} changelog: ${{ steps.changelog.outputs.changelog }} token: ${{ secrets.GITHUB_TOKEN }} - - name: Trigger Release Build - if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_final_env == 'true' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.release-data.outputs.sem_version }} - run: | - # Only dispatch on real GitHub. In act/gitea e2e environments - # GITHUB_SERVER_URL is http://gitea:3000 and the Release workflow - # doesn't exist, so skip silently. - if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then - echo "Skipping Release dispatch (not running on github.com: $GITHUB_SERVER_URL)" - exit 0 - fi - gh workflow run Release \ - --repo "${{ github.repository }}" \ - --ref "$TAG" - - name: Finalize Promotion if: ${{ github.event.inputs.dry_run != 'true' }} env: diff --git a/internal/config/types.go b/internal/config/types.go index cc5b7a2f..0230fd61 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -496,6 +496,13 @@ type PublishConfig struct { type ReleaseConfig struct { Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` // true = disabled Tag string `yaml:"tag,omitempty" json:"tag,omitempty"` // callback.output reference for external releases + + // Workflow is the optional reusable-workflow path dispatched after a final + // release is published, for example to build and attach release binaries to + // the published release (e.g. ".github/workflows/release-build.yaml"). When + // unset, no release-build dispatch is emitted and publishing a release does + // not trigger any follow-on workflow. + Workflow string `yaml:"workflow,omitempty" json:"workflow,omitempty"` } // ExternalRepoConfig defines an external repository that this primary coordinates diff --git a/internal/generate/promote.go b/internal/generate/promote.go index ee4aa926..f3e3cefe 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -1199,28 +1199,26 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) - // Trigger the Release workflow to build and attach binaries. GitHub does - // not reliably fire release event webhooks when a draft release is - // PATCHed to non-draft via API calls inside a workflow run (cf. #86). - // An explicit workflow_dispatch is the only reliably-triggered path. - // Only runs on publish (final release creation), not on prerelease + // Trigger the configured release-build workflow to build and attach binaries. + // GitHub does not reliably fire release event webhooks when a draft release is + // PATCHed to non-draft via API calls inside a workflow run (cf. #86), so an + // explicit workflow_dispatch is the only reliably-triggered path. This step is + // emitted only when `release.workflow` is set, dispatches that configured + // workflow, and runs on publish (final release creation) but not on prerelease // or env-to-env promotions. - sb.WriteString(" - name: Trigger Release Build\n") - sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_final_env == 'true' }}\n") - sb.WriteString(" env:\n") - fmt.Fprintf(sb, " GITHUB_TOKEN: %s\n", g.getReleaseTokenRef()) - sb.WriteString(" TAG: ${{ steps.release-data.outputs.sem_version }}\n") - sb.WriteString(" run: |\n") - sb.WriteString(" # Only dispatch on real GitHub. In act/gitea e2e environments\n") - sb.WriteString(" # GITHUB_SERVER_URL is http://gitea:3000 and the Release workflow\n") - sb.WriteString(" # doesn't exist, so skip silently.\n") - sb.WriteString(" if [[ \"$GITHUB_SERVER_URL\" != \"https://github.com\" ]]; then\n") - sb.WriteString(" echo \"Skipping Release dispatch (not running on github.com: $GITHUB_SERVER_URL)\"\n") - sb.WriteString(" exit 0\n") - sb.WriteString(" fi\n") - sb.WriteString(" gh workflow run Release \\\n") - sb.WriteString(" --repo \"${{ github.repository }}\" \\\n") - sb.WriteString(" --ref \"$TAG\"\n\n") + if g.config.Release != nil && g.config.Release.Workflow != "" { + sb.WriteString(" - name: Trigger Release Build\n") + sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_final_env == 'true' }}\n") + sb.WriteString(" env:\n") + fmt.Fprintf(sb, " GITHUB_TOKEN: %s\n", g.getReleaseTokenRef()) + sb.WriteString(" TAG: ${{ steps.release-data.outputs.sem_version }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" # Dispatch the configured release-build workflow against the\n") + sb.WriteString(" # published tag so it can build and attach release binaries.\n") + sb.WriteString(" gh workflow run " + normalizeWorkflowPath(g.config.Release.Workflow) + " \\\n") + sb.WriteString(" --repo \"${{ github.repository }}\" \\\n") + sb.WriteString(" --ref \"$TAG\"\n\n") + } // Publish callback: invoke once per configured build so users can retag // artifacts in their registries (Docker, Helm, npm, etc.). Only emitted diff --git a/internal/generate/release_build_dispatch_test.go b/internal/generate/release_build_dispatch_test.go new file mode 100644 index 00000000..de0cd6c8 --- /dev/null +++ b/internal/generate/release_build_dispatch_test.go @@ -0,0 +1,92 @@ +package generate + +import ( + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generatePromote builds a promote workflow from the given config and returns +// its rendered content. It centralizes the generator setup so the +// release-build dispatch table tests stay focused on the assertions. +func generatePromote(t *testing.T, cfg *config.TrunkConfig) string { + t.Helper() + gen := NewPromoteGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + return content +} + +// TestPromoteGenerator_ReleaseBuildDispatch asserts that the finalize stage only +// emits the "Trigger Release Build" step when release.workflow is configured, and +// that when emitted it dispatches the configured reusable workflow rather than a +// hardcoded literal. +func TestPromoteGenerator_ReleaseBuildDispatch(t *testing.T) { + tests := []struct { + name string + release *config.ReleaseConfig + wantStep bool + wantDispatch string + notWantDispatch string + }{ + { + name: "no release workflow configured omits the step", + release: nil, + wantStep: false, + notWantDispatch: "gh workflow run Release", + }, + { + name: "release config without workflow omits the step", + release: &config.ReleaseConfig{Tag: "callback.output.release_id"}, + wantStep: false, + notWantDispatch: "gh workflow run Release", + }, + { + name: "release workflow configured emits a dispatch to it", + release: &config.ReleaseConfig{Workflow: ".github/workflows/release-build.yaml"}, + wantStep: true, + wantDispatch: "gh workflow run ./.github/workflows/release-build.yaml", + }, + { + name: "bare release workflow filename is normalized", + release: &config.ReleaseConfig{Workflow: "release-build.yaml"}, + wantStep: true, + wantDispatch: "gh workflow run ./.github/workflows/release-build.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Release: tt.release, + } + + content := generatePromote(t, cfg) + + if tt.wantStep { + assert.Contains(t, content, "- name: Trigger Release Build", + "expected the Trigger Release Build step when release.workflow is set") + body := stepRunBody(t, content, "Trigger Release Build") + assert.Contains(t, body, tt.wantDispatch, + "expected the step to dispatch the configured release workflow") + assert.NotContains(t, content, "gh workflow run Release \\", + "the configured dispatch must not fall back to the hardcoded Release literal") + } else { + assert.NotContains(t, content, "Trigger Release Build", + "the Trigger Release Build step must be absent without release.workflow") + assert.NotContains(t, content, tt.notWantDispatch, + "no hardcoded Release dispatch may be emitted without release.workflow") + } + + // The literal "gh workflow run Release " (trailing space before the + // line continuation) must never appear; only normalized paths. + assert.False(t, strings.Contains(content, "gh workflow run Release \\"), + "the hardcoded Release dispatch must never be emitted") + }) + } +}