From 05248e591e011032544502898af3f07668730835 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 26 Jun 2026 15:02:08 -0400 Subject: [PATCH] feat(release): nightly-gated rc-cutting and promotion with a dispatch test vector Move cascade's release cadence off per-merge and onto a green nightly. Add an opt-in release_trigger=dispatch manifest field so the generated orchestrate omits its push trigger (example repos keep push; only cascade opts in). Add nightly-release.yaml: a scheduled gate that runs only when code or requisite manifest files changed since the last final release, cuts the candidate rc, and lets the staged fleet and auto-promote publish only on green. It also exposes a workflow_dispatch vector with force (bypass the change-skip) and dry_run (cut a dryrun-tagged prerelease that runs the full fleet but is frozen out of publication by auto-promote's rc-only gate) so the whole path is testable on demand. Part of #373; closes #375. Signed-off-by: Joshua Temple --- .github/manifest.yaml | 1 + .github/workflows/fleet-e2e.yaml | 25 +- .github/workflows/nightly-release.yaml | 255 ++++++++++++++++++ .github/workflows/orchestrate.yaml | 8 - docs/public/manifest.schema.json | 5 + e2e/harness/scenario.go | 5 + .../40-release-trigger-dispatch-only.yaml | 44 +++ internal/config/schema_v1.go | 10 + internal/config/types.go | 12 + internal/config/validate_v1.go | 5 + internal/generate/dispatch_only_test.go | 65 +++++ internal/generate/generator.go | 27 +- internal/schema/manifest.schema.json | 5 + schema/manifest.schema.json | 5 + 14 files changed, 445 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/nightly-release.yaml create mode 100644 e2e/scenarios/40-release-trigger-dispatch-only.yaml create mode 100644 internal/generate/dispatch_only_test.go diff --git a/.github/manifest.yaml b/.github/manifest.yaml index 740f406..78f6fb4 100644 --- a/.github/manifest.yaml +++ b/.github/manifest.yaml @@ -1,6 +1,7 @@ ci: config: trunk_branch: main + release_trigger: dispatch state_token: ${{ secrets.CASCADE_STATE_TOKEN }} release_token: ${{ secrets.CASCADE_STATE_TOKEN }} triggers: diff --git a/.github/workflows/fleet-e2e.yaml b/.github/workflows/fleet-e2e.yaml index 9e658ee..aa658ef 100644 --- a/.github/workflows/fleet-e2e.yaml +++ b/.github/workflows/fleet-e2e.yaml @@ -75,8 +75,13 @@ jobs: needs: plan runs-on: ubuntu-latest # Top-level guard: only fan out for a manual dispatch, or a green - # Release run that was a push of an rc tag. This filters out - # non-rc tag publishes and any non-success completions. + # Release run that was a push of a candidate tag. This filters out + # non-candidate tag publishes and any non-success completions. + # + # A candidate is an rc tag (vX.Y.Z-rc.N) or a dry-run tag + # (vX.Y.Z-dryrun.N). Accepting -dryrun. lets a nightly dry run fan out + # across the full staged fleet exactly like a real rc; auto-promote's + # unchanged -rc.-only gate still keeps a dry run from ever publishing. # # workflow_run.head_branch carries the short ref name of whatever triggered # the source run. For a tag push that is the tag's short name (e.g. @@ -88,7 +93,8 @@ jobs: (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && startsWith(github.event.workflow_run.head_branch, 'v') && - contains(github.event.workflow_run.head_branch, '-rc.')) + (contains(github.event.workflow_run.head_branch, '-rc.') || + contains(github.event.workflow_run.head_branch, '-dryrun.'))) permissions: contents: read actions: read @@ -114,14 +120,15 @@ jobs: # Primary path: the rc tag short-name from the source push run. VERSION="$WR_HEAD_BRANCH" elif [ -n "$WR_HEAD_SHA" ]; then - # Fallback: head_branch was empty; resolve the rc tag pointing at the - # source run's head_sha. Tolerated to be empty (dispatch with no - # input), so guard the lookup. - # A sha can carry more than one rc tag; pick the highest by version - # sort so selection is deterministic regardless of API ordering. + # Fallback: head_branch was empty; resolve the candidate tag pointing + # at the source run's head_sha. Tolerated to be empty (dispatch with + # no input), so guard the lookup. + # A sha can carry more than one candidate tag; pick the highest by + # version sort so selection is deterministic regardless of API + # ordering. Accept rc and dryrun tags (see the resolve gate). VERSION=$(gh api "repos/${GITHUB_REPOSITORY}/tags" \ --jq ".[] | select(.commit.sha == \"$WR_HEAD_SHA\") | .name" \ - | grep -- '-rc\.' | sort -V -r | head -n 1 || true) + | grep -E -- '-(rc|dryrun)\.' | sort -V -r | head -n 1 || true) else VERSION="" fi diff --git a/.github/workflows/nightly-release.yaml b/.github/workflows/nightly-release.yaml new file mode 100644 index 0000000..dde7ab5 --- /dev/null +++ b/.github/workflows/nightly-release.yaml @@ -0,0 +1,255 @@ +# Maintainer CI: not part of cascade's generated output. +# +# Nightly release gate. cascade's orchestrate workflow is dispatch-only +# (release_trigger: dispatch in .github/manifest.yaml), so a trunk merge no +# longer cuts a release candidate on its own. This workflow is the single gate +# that decides, once per night, whether main has accumulated release-worthy +# changes since the last published release and, if so, dispatches orchestrate +# to cut the candidate. The existing orchestrate -> Release -> Fleet E2E -> +# Auto-promote chain runs unchanged from there. +# +# Two manual inputs: +# force bypass the change-since-last-release skip (release even if main is +# unchanged). +# dry_run rehearse the whole path (candidate cut, Release, full fleet, +# artifact handoff, auto-promote wiring) WITHOUT publishing, by +# cutting a vX.Y.Z-dryrun.N prerelease tag instead of an rc. The +# fleet accepts -dryrun. tags; auto-promote promotes only -rc. tags, +# so a dry run can never publish (see auto-promote.yaml's gate). +name: Nightly release gate + +on: + schedule: + # 07:00 UTC daily, off-peak, after late-day merges settle. A missed night + # just defers: the diff is always measured against the last release, not the + # last night, so accumulated changes still release on the next run. + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + force: + description: 'Bypass the change-since-last-release skip (run even if main is unchanged).' + type: boolean + default: false + dry_run: + description: 'Rehearse cut + Release + full fleet WITHOUT publishing the final release.' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: nightly-release + cancel-in-progress: false + +jobs: + decide: + name: Decide whether to release + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + run: ${{ steps.decide.outputs.run }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Full history + tags so the last-release ref and the diff resolve. + fetch-depth: 0 + + - name: Decide whether main changed since the last release + id: decide + env: + # Empty on the schedule path; 'true' only when a maintainer ticks the + # box on a manual run. + FORCE: ${{ github.event.inputs.force }} + run: | + set -euo pipefail + + # force short-circuits the change-skip entirely. + if [ "${FORCE:-false}" = "true" ]; then + echo "::notice::force=true; releasing even if main is unchanged." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git fetch --tags --force --quiet origin + + # Last-release ref: the latest FINAL release tag only. The exact + # vX.Y.Z shape excludes -rc. and -dryrun. prerelease tags, so a + # candidate or a leftover dry-run tag can never become the diff base. + LAST_RELEASE=$(git tag -l 'v*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V \ + | tail -n1 || true) + + # Fail open: with no final release yet (bootstrap), or an unresolvable + # ref, proceed rather than silently skip a real release. + if [ -z "$LAST_RELEASE" ]; then + echo "::notice::No final vX.Y.Z release found; failing open (run=true)." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! git rev-parse -q --verify "refs/tags/${LAST_RELEASE}" >/dev/null; then + echo "::warning::Last-release ref ${LAST_RELEASE} is unresolvable; failing open (run=true)." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "::notice::Diffing ${LAST_RELEASE}..origin/main" + CHANGED=$(git diff --name-only "${LAST_RELEASE}..origin/main" || true) + if [ -z "$CHANGED" ]; then + echo "::notice::No file changes since ${LAST_RELEASE}; skipping." + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Walk the changed files. A file is release-worthy when it is in the + # include set AND not in the exclude set. The manifest is special: a + # pure-state '[skip ci]' write only touches the ci.state subtree, so it + # counts only when its non-state subtree actually changed. + relevant=false + manifest_nonstate_changed() { + local old new + old=$(git show "${LAST_RELEASE}:.github/manifest.yaml" 2>/dev/null \ + | yq 'del(.ci.state)' - 2>/dev/null || echo "__missing_old__") + new=$(git show "origin/main:.github/manifest.yaml" 2>/dev/null \ + | yq 'del(.ci.state)' - 2>/dev/null || echo "__missing_new__") + [ "$old" != "$new" ] + } + + while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + # Exclude: never a release on their own. + docs/*|*.md|CONTRIBUTING*|LICENSE*|*/plans/*) + continue + ;; + # Manifest: only its non-state subtree is release-worthy. + .github/manifest.yaml) + if manifest_nonstate_changed; then + echo "::notice::Release-worthy change: ${f} (non-state subtree)" + relevant=true + fi + ;; + # Include: code and shipped action surface. + cmd/*|internal/*|go.mod|go.sum|.github/actions/*) + echo "::notice::Release-worthy change: ${f}" + relevant=true + ;; + *) + # Anything else is not release-worthy on its own. + ;; + esac + done <<< "$CHANGED" + + if [ "$relevant" = "true" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Only non-release-worthy paths changed since ${LAST_RELEASE}; skipping." + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + - name: Summarize decision + if: always() + env: + RUN: ${{ steps.decide.outputs.run }} + FORCE: ${{ github.event.inputs.force }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + run: | + { + echo "## Nightly release decision" + echo "" + echo "| input | value |" + echo "| --- | --- |" + echo "| run | ${RUN:-} |" + echo "| force | ${FORCE:-false} |" + echo "| dry_run | ${DRY_RUN:-false} |" + } >> "$GITHUB_STEP_SUMMARY" + + dispatch: + name: Dispatch the release candidate + needs: decide + if: needs.decide.outputs.run == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + # Real run: dispatch the (dispatch-only) orchestrate workflow with the + # trigger-capable PAT. GITHUB_TOKEN would dispatch but its downstream + # rc-tag push would not fire Release, killing the chain silently. + # orchestrate's finalize cuts the rc and the existing chain proceeds. + - name: Dispatch orchestrate (real run) + if: github.event.inputs.dry_run != 'true' + env: + GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }} + run: | + set -euo pipefail + gh workflow run orchestrate.yaml \ + --repo "${GITHUB_REPOSITORY}" \ + --field dry_run=false + echo "::notice::Dispatched orchestrate (real release candidate)." + + # Dry run: instead of dispatching orchestrate (whose finalize would route + # the tag through cascade's rc-only version parser and reject a dryrun + # label), the gate cuts the candidate itself with a -dryrun. identity. + # The tag is the only carrier that survives the workflow_run boundaries: + # Release builds it, the (broadened) fleet validates it, and auto-promote's + # unchanged -rc.-only gate rejects it, so nothing publishes by construction. + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + if: github.event.inputs.dry_run == 'true' + with: + fetch-depth: 0 + # Check out with the PAT so the tag push below triggers Release. + token: ${{ secrets.CASCADE_STATE_TOKEN }} + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + if: github.event.inputs.dry_run == 'true' + with: + go-version: "1.25" + + - name: Compute candidate version (dry run) + id: version + if: github.event.inputs.dry_run == 'true' + run: | + set -euo pipefail + go build -o /tmp/cascade ./cmd/cascade + # orchestrate setup computes the next candidate against current HEAD + # and writes `version=vX.Y.Z-rc.N` to $GITHUB_OUTPUT (read-only; only + # finalize writes state). + /tmp/cascade orchestrate setup \ + --config .github/manifest.yaml \ + --gha-output + + - name: Cut and push dry-run tag (dry run) + if: github.event.inputs.dry_run == 'true' + env: + # The rc-shaped candidate (vX.Y.Z-rc.N) computed above. + CANDIDATE: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if [ -z "${CANDIDATE:-}" ]; then + echo "::error::No candidate version computed; cannot cut a dry-run tag." + exit 1 + fi + # Strip the -rc.N suffix to the vX.Y.Z core, then label the rehearsal + # tag with -dryrun. for a unique, monotonic, well-formed + # prerelease that auto-promote will never publish. + BASE="${CANDIDATE%-rc.*}" + DRYRUN_TAG="${BASE}-dryrun.${GITHUB_RUN_NUMBER}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git rev-parse -q --verify "refs/tags/${DRYRUN_TAG}" >/dev/null; then + echo "::notice::Tag ${DRYRUN_TAG} already exists; reusing." + else + git tag -a "${DRYRUN_TAG}" "origin/main" \ + -m "Dry-run release rehearsal for ${BASE} (no publish)" + git push origin "refs/tags/${DRYRUN_TAG}" + fi + echo "::notice::Pushed dry-run tag ${DRYRUN_TAG}; Release will build it and the full fleet will validate it. Auto-promote's -rc.-only gate keeps it unpublished. The -dryrun.* tag is excluded from the change-detection base and should be pruned after the run." + { + echo "## Dry-run candidate" + echo "" + echo "Cut \`${DRYRUN_TAG}\` (rehearses \`${CANDIDATE}\`). This will run Release + the full fleet but cannot publish." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 35cf489..5466455 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -4,14 +4,6 @@ name: Orchestrate CI/CD on: - push: - branches: [main] - paths: - - 'cmd/**' - - 'e2e/**' - - 'go.mod' - - 'go.sum' - - 'internal/**' workflow_dispatch: inputs: dry_run: diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index c2e52b2..0594ada 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -70,6 +70,11 @@ "items": { "type": "string" }, "description": "Global path patterns for the orchestrate workflow paths filter. When set, these are used exclusively instead of per-callback triggers." }, + "release_trigger": { + "type": "string", + "enum": ["push", "dispatch"], + "description": "How the generated orchestrate workflow fires. \"push\" (default) keeps the trunk-push trigger plus workflow_dispatch. \"dispatch\" drops the push trigger so orchestrate runs only on workflow_dispatch, for a maintainer-gated release cadence." + }, "environments": { "type": "array", "items": { "type": "string" }, diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index 1787552..a2fad0c 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -31,6 +31,11 @@ type Config struct { TrunkBranch string `yaml:"trunk_branch"` Environments []string `yaml:"environments"` JobTimeoutMinutes int `yaml:"job_timeout_minutes,omitempty"` + // ReleaseTrigger carries the release_trigger field through to the generated + // manifest so a scenario can make orchestrate dispatch-only. Without this + // field the value is silently dropped on marshal, the same hazard the token + // fields below document, and the generated orchestrate keeps its push trigger. + ReleaseTrigger string `yaml:"release_trigger,omitempty"` // ReleaseToken carries the release_token field through to the generated // manifest. It accepts a full ${{ secrets.* }} expression or a bare secret // name; the generator normalizes a bare name to a resolvable expression. diff --git a/e2e/scenarios/40-release-trigger-dispatch-only.yaml b/e2e/scenarios/40-release-trigger-dispatch-only.yaml new file mode 100644 index 0000000..4c7824f --- /dev/null +++ b/e2e/scenarios/40-release-trigger-dispatch-only.yaml @@ -0,0 +1,44 @@ +name: "Release trigger dispatch-only" +description: | + Verifies that release_trigger: dispatch makes the generated orchestrate + workflow omit its push: trigger so it runs only on workflow_dispatch. A + maintainer-owned gate (for example a nightly schedule) then decides when a + release candidate is cut, instead of every trunk merge cutting one. + + Covers: + - the push: trigger and its branches:/paths: filter are gone + - workflow_dispatch and its dry_run input survive + - the default (push) behavior is unaffected, asserted by 13-dispatch-inputs + and every other generator scenario that does not set release_trigger + + Generator-output verification only. + +config: + trunk_branch: main + release_trigger: dispatch + environments: [dev] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: [] + +steps: + - name: "Initial commit; assert orchestrate omits push trigger" + action: commit + commit: + message: "feat: add app" + files: + src/app.go: | + package main + func main() {} + expect: + workflow_files: + - path: ".github/workflows/orchestrate.yaml" + contains: + - " workflow_dispatch:" + - " dry_run:" + not_contains: + - " push:" + - " branches: [main]" + - " paths:" diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 472f5de..9ad7c38 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -311,6 +311,16 @@ const ( PinModeSHA = "sha" ) +// Release trigger modes for the generated orchestrate workflow. +const ( + // ReleaseTriggerPush is the default: orchestrate fires on trunk pushes + // (filtered by triggers:) plus workflow_dispatch. + ReleaseTriggerPush = "push" + // ReleaseTriggerDispatch drops the push: trigger so orchestrate runs only + // on workflow_dispatch (maintainer-gated release cadence). + ReleaseTriggerDispatch = "dispatch" +) + // TelemetryConfig is the reserved vendor-neutral metrics seam. type TelemetryConfig struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` diff --git a/internal/config/types.go b/internal/config/types.go index 8c405c7..e81cf6e 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -117,6 +117,12 @@ type TrunkConfig struct { SchemaVersion int `yaml:"schema_version,omitempty" json:"schema_version,omitempty"` // Manifest schema generation (default: CurrentSchemaVersion when omitted) TrunkBranch string `yaml:"trunk_branch" json:"trunk_branch"` Triggers []string `yaml:"triggers,omitempty" json:"triggers,omitempty"` // Global triggers for orchestration workflow paths filter + // ReleaseTrigger selects how the generated orchestrate workflow fires. + // "" or "push" (default) keeps the push-on-trunk + workflow_dispatch + // triggers. "dispatch" drops the push: trigger so orchestrate runs only on + // workflow_dispatch, letting a maintainer-owned gate decide when a release + // candidate is cut. Opt-in; repos that do not set it keep push triggers. + ReleaseTrigger string `yaml:"release_trigger,omitempty" json:"release_trigger,omitempty"` Environments []string `yaml:"environments,omitempty" json:"environments,omitempty"` // Empty = no-environment setup (library/CLI projects) CLIVersion string `yaml:"cli_version,omitempty" json:"cli_version,omitempty"` // cascade CLI version (e.g., v1.0.0) TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` // Version tag prefix (default: "v") @@ -177,6 +183,12 @@ type ConcurrencyConfig struct { CancelInProgress bool `yaml:"cancel_in_progress" json:"cancel_in_progress"` } +// OrchestrateDispatchOnly reports whether the generated orchestrate workflow +// should omit its push: trigger and run only on workflow_dispatch. +func (c *TrunkConfig) OrchestrateDispatchOnly() bool { + return c.ReleaseTrigger == ReleaseTriggerDispatch +} + // GetConcurrencyGroup returns the configured group expression or the default. func (c *TrunkConfig) GetConcurrencyGroup() string { if c.Concurrency != nil && c.Concurrency.Group != "" { diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index b428a79..3621b1d 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -278,6 +278,11 @@ func validateConfigLevel(cfg *TrunkConfig) []string { errs = append(errs, "pin_mode must be one of: tag, sha") } + // release_trigger must be push or dispatch. + if cfg.ReleaseTrigger != "" && cfg.ReleaseTrigger != ReleaseTriggerPush && cfg.ReleaseTrigger != ReleaseTriggerDispatch { + errs = append(errs, "release_trigger must be one of: push, dispatch") + } + // dispatch_inputs may not shadow generator-owned reserved names, and choice // inputs need options. for _, name := range sortedKeys(toStringKeyed(cfg.DispatchInputs)) { diff --git a/internal/generate/dispatch_only_test.go b/internal/generate/dispatch_only_test.go new file mode 100644 index 0000000..550c405 --- /dev/null +++ b/internal/generate/dispatch_only_test.go @@ -0,0 +1,65 @@ +package generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dispatchOnlyTestConfig builds a minimal orchestrate config with a single +// build callback and a triggers paths filter, optionally dispatch-only. +func dispatchOnlyTestConfig(t *testing.T, dispatchOnly bool) (*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", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + if dispatchOnly { + cfg.ReleaseTrigger = config.ReleaseTriggerDispatch + } + return cfg, tmpDir +} + +// TestGenerator_DefaultEmitsPushTrigger asserts the generated orchestrate +// workflow keeps its push: trigger (and paths filter) by default, so example +// repos that do not opt in continue to run orchestrate on every trunk merge. +func TestGenerator_DefaultEmitsPushTrigger(t *testing.T) { + cfg, tmpDir := dispatchOnlyTestConfig(t, false) + + result, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + assert.Contains(t, result, " push:\n", "default orchestrate must keep the push: trigger") + assert.Contains(t, result, " branches: [main]\n", "default orchestrate must push on the trunk branch") + assert.Contains(t, result, " paths:\n", "default orchestrate must keep its paths filter") + assert.Contains(t, result, " workflow_dispatch:\n", "default orchestrate must keep workflow_dispatch") +} + +// TestGenerator_DispatchOnlyOmitsPushTrigger asserts that when +// release_trigger: dispatch is set, the generated orchestrate workflow omits +// its push: trigger entirely (becoming workflow_dispatch-driven) while keeping +// the dispatch trigger and its dry_run input intact. +func TestGenerator_DispatchOnlyOmitsPushTrigger(t *testing.T) { + cfg, tmpDir := dispatchOnlyTestConfig(t, true) + + result, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + assert.NotContains(t, result, " push:\n", "dispatch-only orchestrate must omit the push: trigger") + assert.NotContains(t, result, " branches: [main]\n", "dispatch-only orchestrate must not declare push branches") + assert.NotContains(t, result, " paths:\n", "dispatch-only orchestrate must not declare a push paths filter") + // The dispatch trigger and its dry_run input survive. + assert.Contains(t, result, " workflow_dispatch:\n", "dispatch-only orchestrate must keep workflow_dispatch") + assert.Contains(t, result, " dry_run:\n", "dispatch-only orchestrate must keep the dry_run input") +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index fc06c5a..4c09e3a 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -597,16 +597,23 @@ func (g *Generator) writeHeader(sb *strings.Builder) { func (g *Generator) writeWorkflowTriggers(sb *strings.Builder) { sb.WriteString("name: Orchestrate CI/CD\n\n") sb.WriteString("on:\n") - sb.WriteString(" push:\n") - fmt.Fprintf(sb, " branches: [%s]\n", g.config.TrunkBranch) - - // Add paths filter based on all configured triggers - // This prevents orchestration from running when no relevant files changed - triggers := g.config.GetAllTriggers() - if len(triggers) > 0 { - sb.WriteString(" paths:\n") - for _, trigger := range triggers { - fmt.Fprintf(sb, " - '%s'\n", trigger) + + // release_trigger: dispatch drops the push: trigger so orchestrate runs only + // on workflow_dispatch. A maintainer-owned gate then decides when a release + // candidate is cut, instead of every trunk merge producing one. Default + // (push) keeps the trunk-push trigger and its paths filter. + if !g.config.OrchestrateDispatchOnly() { + sb.WriteString(" push:\n") + fmt.Fprintf(sb, " branches: [%s]\n", g.config.TrunkBranch) + + // Add paths filter based on all configured triggers + // This prevents orchestration from running when no relevant files changed + triggers := g.config.GetAllTriggers() + if len(triggers) > 0 { + sb.WriteString(" paths:\n") + for _, trigger := range triggers { + fmt.Fprintf(sb, " - '%s'\n", trigger) + } } } diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index c2e52b2..0594ada 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -70,6 +70,11 @@ "items": { "type": "string" }, "description": "Global path patterns for the orchestrate workflow paths filter. When set, these are used exclusively instead of per-callback triggers." }, + "release_trigger": { + "type": "string", + "enum": ["push", "dispatch"], + "description": "How the generated orchestrate workflow fires. \"push\" (default) keeps the trunk-push trigger plus workflow_dispatch. \"dispatch\" drops the push trigger so orchestrate runs only on workflow_dispatch, for a maintainer-gated release cadence." + }, "environments": { "type": "array", "items": { "type": "string" }, diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index c2e52b2..0594ada 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -70,6 +70,11 @@ "items": { "type": "string" }, "description": "Global path patterns for the orchestrate workflow paths filter. When set, these are used exclusively instead of per-callback triggers." }, + "release_trigger": { + "type": "string", + "enum": ["push", "dispatch"], + "description": "How the generated orchestrate workflow fires. \"push\" (default) keeps the trunk-push trigger plus workflow_dispatch. \"dispatch\" drops the push trigger so orchestrate runs only on workflow_dispatch, for a maintainer-gated release cadence." + }, "environments": { "type": "array", "items": { "type": "string" },