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
1 change: 1 addition & 0 deletions .github/manifest.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
25 changes: 16 additions & 9 deletions .github/workflows/fleet-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
255 changes: 255 additions & 0 deletions .github/workflows/nightly-release.yaml
Original file line number Diff line number Diff line change
@@ -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:-<unset>} |"
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.<run_number> 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"
8 changes: 0 additions & 8 deletions .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
5 changes: 5 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions e2e/scenarios/40-release-trigger-dispatch-only.yaml
Original file line number Diff line number Diff line change
@@ -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:"
Loading
Loading