diff --git a/.github/manifest.yaml b/.github/manifest.yaml index 200d1806..cb819d05 100644 --- a/.github/manifest.yaml +++ b/.github/manifest.yaml @@ -21,6 +21,8 @@ ci: - name: cli workflow: .github/workflows/build-cli.yaml triggers: [] + release: + workflow: .github/workflows/release.yaml changelog: contributors: true state: diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index e2dc4d35..257ef5e4 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -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 }} diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index 0648b3bc..314fd5e1 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -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: diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index fa91a3dd..977b300a 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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 `) 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 diff --git a/internal/generate/candidate_dispatch_test.go b/internal/generate/candidate_dispatch_test.go new file mode 100644 index 00000000..c8e2fcb8 --- /dev/null +++ b/internal/generate/candidate_dispatch_test.go @@ -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") +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index cdbdfdee..6dc057c8 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -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) @@ -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)