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
2 changes: 2 additions & 0 deletions .github/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ ci:
- name: cli
workflow: .github/workflows/build-cli.yaml
triggers: []
release:
workflow: .github/workflows/release.yaml
changelog:
contributors: true
state:
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ jobs:
changelog: ${{ steps.changelog.outputs.changelog }}
previous_tag: ${{ needs.setup.outputs.previous_tag }}
token: ${{ secrets.CASCADE_STATE_TOKEN }}
- name: Dispatch Release Candidate Build
if: ${{ github.server_url == 'https://github.com' }}
env:
GITHUB_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
TAG: ${{ needs.setup.outputs.version }}
run: |
# The tag-push trigger is unreliable here: the candidate tag can
# point at a state commit whose message suppresses CI, so an explicit
# dispatch against the tag is the dependable way to build the candidate.
gh workflow run ./.github/workflows/release.yaml \
--repo "${{ github.repository }}" \
--ref "$TAG"
- name: Update Manifest
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/promote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,18 @@ jobs:
delete_tag: ${{ steps.release-data.outputs.rc_version }}
changelog: ${{ steps.changelog.outputs.changelog }}
token: ${{ secrets.CASCADE_STATE_TOKEN }}
- name: Trigger Release Build
if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_final_env == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
TAG: ${{ steps.release-data.outputs.sem_version }}
run: |
# Dispatch the configured release-build workflow against the
# published tag so it can build and attach release binaries.
gh workflow run ./.github/workflows/release.yaml \
--repo "${{ github.repository }}" \
--ref "$TAG"

- name: Finalize Promotion
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
Expand Down
3 changes: 3 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,10 +517,13 @@ ci:
|-------|------|---------|-------------|
| `disabled` | bool | false | Disable framework release management |
| `tag` | string | - | callback.output reference for an external release tool |
| `workflow` | string | - | Release workflow dispatched against a release tag to build and attach binaries. See the dispatch note below. |
| `version_overrides` | object | - | Reserved pointer (`dir:`) to maintainer-committed version-intent override files. Reserved shape only; see [Versioning](/versioning/#reserved-shape-version-intent-overrides). |

Omit this section to use framework defaults (creates releases with conventional commit changelogs).

When `workflow` is set, cascade dispatches it (via `gh workflow run --ref <tag>`) rather than relying only on the tag-push trigger. GitHub does not reliably start a tag-push workflow when the tagged commit carries a CI-skip marker, and release tags routinely point at a state commit that does. The explicit dispatch fires in two places: the promote flow dispatches it against the final tag when a release publishes, and, when `release_trigger: dispatch` is set, the orchestrate finalize job dispatches it against the release-candidate tag as soon as the candidate is cut. Restricting the candidate dispatch to dispatch-mode trunks keeps it from racing the native tag-push trigger a push-mode trunk relies on, so a candidate is never built twice.

### changelog Section

```yaml
Expand Down
102 changes: 102 additions & 0 deletions internal/generate/candidate_dispatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package generate

import (
"os"
"path/filepath"
"testing"

"github.com/stablekernel/cascade/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// candidateDispatchConfig builds a minimal orchestrate config with a single
// build callback, allowing the caller to toggle dispatch mode and the
// release.workflow that the candidate build is dispatched against.
func candidateDispatchConfig(t *testing.T, dispatchOnly bool, release *config.ReleaseConfig) (*config.TrunkConfig, string) {
t.Helper()
tmpDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644))

cfg := &config.TrunkConfig{
TrunkBranch: "main",
Builds: []config.BuildConfig{
{Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}},
},
Release: release,
}
if dispatchOnly {
cfg.ReleaseTrigger = config.ReleaseTriggerDispatch
}
return cfg, tmpDir
}

// TestGenerator_CandidateDispatchStep asserts that the orchestrate finalize job
// emits an explicit release-workflow dispatch against the candidate tag only
// when the trunk is dispatch-driven AND a release.workflow is configured. The
// tag-push trigger is unreliable for a candidate tag that points at a CI-skip
// state commit, so the explicit dispatch is the dependable build path; gating it
// on dispatch mode keeps it from racing a push-mode trunk's native tag trigger.
func TestGenerator_CandidateDispatchStep(t *testing.T) {
tests := []struct {
name string
dispatchOnly bool
release *config.ReleaseConfig
wantStep bool
}{
{
name: "dispatch mode with release workflow emits the dispatch",
dispatchOnly: true,
release: &config.ReleaseConfig{Workflow: ".github/workflows/release.yaml"},
wantStep: true,
},
{
name: "dispatch mode without release workflow omits the dispatch",
dispatchOnly: true,
release: nil,
wantStep: false,
},
{
name: "push mode with release workflow omits the dispatch",
dispatchOnly: false,
release: &config.ReleaseConfig{Workflow: ".github/workflows/release.yaml"},
wantStep: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, tmpDir := candidateDispatchConfig(t, tt.dispatchOnly, tt.release)

content, err := NewGenerator(cfg, tmpDir).Generate()
require.NoError(t, err)

if tt.wantStep {
require.Contains(t, content, "- name: Dispatch Release Candidate Build",
"expected the candidate dispatch step in dispatch mode with a release workflow")
body := stepRunBody(t, content, "Dispatch Release Candidate Build")
assert.Contains(t, body, "gh workflow run ./.github/workflows/release.yaml",
"the step must dispatch the configured release workflow, normalized")
assert.Contains(t, body, `--ref "$TAG"`,
"the dispatch must target the candidate tag")
} else {
assert.NotContains(t, content, "Dispatch Release Candidate Build",
"the candidate dispatch step must be absent")
}
})
}
}

// TestGenerator_CandidateDispatchNormalizesBareFilename asserts a bare release
// workflow filename is normalized to a repo-relative path in the dispatch.
func TestGenerator_CandidateDispatchNormalizesBareFilename(t *testing.T) {
cfg, tmpDir := candidateDispatchConfig(t, true, &config.ReleaseConfig{Workflow: "release.yaml"})

content, err := NewGenerator(cfg, tmpDir).Generate()
require.NoError(t, err)

body := stepRunBody(t, content, "Dispatch Release Candidate Build")
assert.Contains(t, body, "gh workflow run ./.github/workflows/release.yaml",
"a bare release workflow filename must normalize to a repo-relative path")
}
42 changes: 42 additions & 0 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,9 @@ func (g *Generator) writeFinalizeJob(sb *strings.Builder, sorted []string) {
g.writeArtifactDownloadStep(sb)
}
g.writeReleaseStep(sb)
// Explicitly fire the configured release workflow against the candidate
// tag when the trunk is dispatch-driven (see the step comment).
g.writeCandidateDispatchStep(sb)
// Upload artifacts to release after it's created
if g.config.HasReleaseArtifacts() {
g.writeArtifactUploadStep(sb)
Expand Down Expand Up @@ -1888,6 +1891,45 @@ func (g *Generator) writeReleaseStep(sb *strings.Builder) {
fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef())
}

// writeCandidateDispatchStep fires the configured release workflow against the
// freshly-cut candidate tag when the trunk runs in dispatch mode.
//
// The finalize job cuts the candidate tag on the setup head SHA, which in steady
// state is a prior state commit whose message carries a CI-skip marker. GitHub
// evaluates that marker on the tagged commit and suppresses every workflow the
// tag push would otherwise start, so the tag-push trigger that builds and
// publishes the candidate never fires. Dispatching the configured release
// workflow explicitly against the tag is the dependable path.
//
// Emitted only when the release is framework-managed, a release workflow is
// configured, and release_trigger is dispatch. Restricting it to dispatch-mode
// trunks keeps it from racing the native tag-push trigger a push-mode trunk
// relies on, so no candidate is ever built twice.
func (g *Generator) writeCandidateDispatchStep(sb *strings.Builder) {
if g.config.HasExternalRelease() {
return
}
if g.config.Release == nil || g.config.Release.Workflow == "" {
return
}
if !g.config.OrchestrateDispatchOnly() {
return
}
sb.WriteString(" - name: Dispatch Release Candidate Build\n")
// Real GitHub only: the act/gitea e2e harness has no workflow-dispatch API.
sb.WriteString(" if: ${{ github.server_url == 'https://github.com' }}\n")
sb.WriteString(" env:\n")
fmt.Fprintf(sb, " GITHUB_TOKEN: %s\n", g.getReleaseTokenRef())
sb.WriteString(" TAG: ${{ needs.setup.outputs.version }}\n")
sb.WriteString(" run: |\n")
sb.WriteString(" # The tag-push trigger is unreliable here: the candidate tag can\n")
sb.WriteString(" # point at a state commit whose message suppresses CI, so an explicit\n")
sb.WriteString(" # dispatch against the tag is the dependable way to build the candidate.\n")
sb.WriteString(" gh workflow run " + normalizeWorkflowPath(g.config.Release.Workflow) + " \\\n")
sb.WriteString(" --repo \"${{ github.repository }}\" \\\n")
sb.WriteString(" --ref \"$TAG\"\n")
}

func (g *Generator) writeArtifactDownloadStep(sb *strings.Builder) {
sb.WriteString(" - name: Download Release Artifacts\n")
writeActionUses(sb, g.config, " ", actionDownloadArtifact)
Expand Down