From 2e776205dcea8510de72fe7e9f5bfedbdb4fe0e8 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 16 Jun 2026 12:10:10 -0400 Subject: [PATCH 1/2] fix: gate release-build dispatch on configured workflow The promote finalize stage emitted a hardcoded 'gh workflow run Release' step on every final publish, guarded only by an act/gitea server-URL check. On real GitHub this always fired and failed for any repo without a workflow named Release (could not find any workflows named Release), blocking final publish. Add an optional release.workflow field and emit the Trigger Release Build step only when it is set, dispatching the configured workflow path. This mirrors the existing publish.workflow pattern. When unset, no dispatch is emitted. Signed-off-by: Joshua Temple --- internal/config/types.go | 7 ++ internal/generate/promote.go | 40 ++++---- .../generate/release_build_dispatch_test.go | 92 +++++++++++++++++++ 3 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 internal/generate/release_build_dispatch_test.go 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") + }) + } +} From 32ce06f396a1957969482b1841f46ae77daed4c2 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 16 Jun 2026 12:29:53 -0400 Subject: [PATCH 2/2] chore: regenerate dogfood workflows for release-build gating Signed-off-by: Joshua Temple --- .github/workflows/promote.yaml | 17 ----------------- 1 file changed, 17 deletions(-) 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: