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
17 changes: 0 additions & 17 deletions .github/workflows/promote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 19 additions & 21 deletions internal/generate/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions internal/generate/release_build_dispatch_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
Loading