Release orchestration
This page describes how cascade releases itself. It is maintainer CI: hand-written
tooling that lives in cascade’s own repository, alongside fleet-e2e.yaml,
auto-promote.yaml, and nightly-release.yaml. None of it is part of cascade’s
generated output. If you are adopting cascade for your own pipelines, this is
background on how the project proves and ships each version, not a feature you
configure.
The release chain is four workflows in sequence. Orchestrate cuts a release candidate and pushes its tag. Release builds and publishes that tag’s assets. The fleet fans out across every example repository to validate the published binary. Auto-promote publishes the final version, but only when the entire fleet is green.
The staged fleet fan-out
Section titled “The staged fleet fan-out”The fleet (.github/workflows/fleet-e2e.yaml)
revalidates the downstream cascade-example-* fleet on live GitHub. Every example
repository dispatches its own scenario-suite.yaml under one shared fleet token. A
green run means this cascade version validated across all eleven example repositories,
each running its own scenario suite in its own repository context.
Dispatching all eleven repositories at once tripped transient GitHub API failures
(401, 403, and 500 responses) on a rotating repository each run, because they all
draw on the same token. The fan-out is therefore split into sequenced lanes that
hold peak live concurrency near two repositories at a time. A gh() transient-retry
wrapper inside each suite remains the per-call backstop; the staging fixes the
structural burst that the wrapper alone could not absorb.
flowchart LR plan[plan] --> resolve[resolve] resolve --> repin[repin fleet to rc] repin --> primary[primary] primary --> dependents[dependents x2] dependents --> heavy[4env alone] heavy --> remainder[remainder, max 2] remainder --> aggregate[Fleet gate]
| Stage | What it does |
|---|---|
plan | Parses the repos selector once and emits the lane gates and matrices every fan-out job keys off. This is the single place the fleet roster lives. |
resolve | Gates the run, resolves the cascade version under test, and peels its tag to the underlying commit. It exposes both as outputs for the repin job and every lane. It also writes version-under-test.txt and a full-run.txt completeness marker for auto-promote to read. |
repin | Re-stamps every example repository’s committed workflows onto the resolved candidate before any suite lane fans out, delegating to the reusable suite-rc-repin.yaml. Every suite lane gates on it, so none starts against a stale committed ref. |
primary | Runs first and must pass before its dependents start. |
dependents | artifact-a and artifact-b mutate the primary’s shared external state, so they run only after the primary is green. The two run together, which is the lane that defines the fleet’s peak of about two repositories. |
heavy | 4env is the heaviest and most fragile repository, so it runs alone in its own job, sequenced after the dependents lane so the two never stack. |
remainder | The light repositories (3env, 2env, single-env, release-only, no-env, callbacks, rollback-dispatch, monorepo) run in a matrix capped at two in flight via max-parallel, sequenced after the heavy lane. |
aggregate | The Fleet gate. It needs every lane, so a green gate means every selected repository passed. Auto-promote keys off this conclusion. |
The fleet triggers on completion of the Release workflow for a release-candidate or
rehearsal tag (the dependable signal that a candidate tag’s assets actually reached the
releases page) and on manual dispatch. The workflow_run trigger carries a branches
filter of *-rc.* and *-dryrun.*, so the final vX.Y.Z publish Release run described
below does not re-enter the fleet.
How each suite lands on the version under test
Section titled “How each suite lands on the version under test”Every example repository’s committed workflow resolves the cascade actions it runs
through a uses: stablekernel/cascade/.github/actions/setup-cli@<ref> reference. GitHub
resolves a job’s uses: references from the workflow file as it existed when the run was
triggered, before any workflow input or in-job step applies. A uses: reference is
therefore a static committed value that no dispatch input can drive, and a reference left
pointing at a throwaway candidate tag that has since been pruned kills the lane at set-up
with an unresolved-action error before the suite can do anything about it.
So the fleet re-stamps the roster centrally, just in time, before it fans out. The repin
job (the reusable
suite-rc-repin.yaml)
clones each repository fresh, points its manifest cli_version and cli_version_sha at
the resolved candidate, rewrites any stale in-repo prerelease references, regenerates the
workflows against the candidate binary (which rewrites the committed setup-cli reference
to a reference that resolves), and pushes the result to main. Every suite lane gates on a
green repin, so none starts against a stale committed reference. Candidate tags stay
throwaway: with repin-first, deleting one mid-cycle is a non-event, because the repin
re-stamps the roster onto the current candidate before any lane runs.
The separate bootstrap tooling pin (the setup-cli version each suite installs before
cascade is even present) is kept current by
suite-bootstrap-pin.yaml,
which runs when cascade publishes a final vX.Y.Z release and moves each suite’s
committed bootstrap pin onto it. That final-release bumper and the pre-fan-out repin
job never collide: they fire on different triggers (a published vX.Y.Z release versus
the candidate under test), target different files (each suite’s scenario-suite.yaml
bootstrap pin versus the generated workflows plus manifest), and run under separate
concurrency groups. Because the fleet already validated that exact version
across every repository before it published, the bump is a proven no-op check rather than
a gate that can block a release.
Changelog range on a final release
Section titled “Changelog range on a final release”Auto-promote tags the newest candidate’s exact commit as the final vX.Y.Z, so a final
release and its last candidate (for example v0.8.0 and v0.8.0-rc.7) point at the same
commit. Left to auto-detect the previous tag, GoReleaser would pick that candidate and
compare a tag against itself, so the final release would publish an empty changelog. The
Release workflow avoids this: for a final tag it resolves the previous stable release with
previous-stable-tag.sh
and passes it as GORELEASER_PREVIOUS_TAG, so a final changelog spans the whole candidate
cycle (for example v0.7.0..v0.8.0). Candidate and dry-run tags leave the value unset and
keep their existing incremental changelogs.
Running a single lane with the repos selector
Section titled “Running a single lane with the repos selector”A full fan-out is the right gate for a release, but it is heavy for developing one
example repository’s suite. The workflow_dispatch path accepts a repos selector
that runs a subset of lanes:
gh workflow run fleet-e2e.yaml -f repos=4envThe selector accepts a single short name, or a comma or space separated list. The
default (no input, which is also the value on the Release-triggered path) is all,
which runs the full fleet. Only the suite lanes honor the selector; the repin job always
covers the full roster so every repository stays on the version under test regardless of
the subset. A lane the selector skips reports skipped and the gate treats it as
satisfied, so a subset run still produces a meaningful verdict over exactly the lanes that
ran.
A selective run never auto-promotes. The plan stage sets full_run=true only when
the selector resolves to all, the resolve stage records that marker in the
full-run.txt artifact, and auto-promote refuses to publish from anything other than
a full run. Only a complete fleet validation is a safe release signal.
Release runs once per tag
Section titled “Release runs once per tag”Every publish path drives the same release.yaml
Release workflow. Each machine path fires it exactly once, through an explicit
workflow_dispatch. Orchestrate (the rc cut) and auto-promote (the final publish) create
their tag with the default GITHUB_TOKEN, whose actions deliberately do not start a
workflow run, so the tag push does not fire the push: tags trigger. They then dispatch
Release explicitly against that tag with the release token. The dispatch is the canonical
trigger: it is immune both to a CI-skip token on the tagged commit and to the API-release
events GitHub does not reliably fire, which would otherwise leave assets unbuilt. Because
the dispatch carries the release token rather than the default token, the resulting
Release run can cascade to fleet-e2e through workflow_run. Only the rc cut does: its
tag matches the fleet’s *-rc.* filter, so it drives the validation lap. Auto-promote’s
final-publish Release runs against the plain vX.Y.Z tag, which the filter excludes, so
the already-validated release does not trigger a second no-op fleet and auto-promote lap.
Cascade also generates a promote.yaml (every manifest with more than one environment
gets one), but it plays no part in cascade’s own release path: cascade has no
intermediate environments to promote between, and its actual final publish is
auto-promote’s hand-authored chain. promote.yaml sits unused, generated in its plain
(non-own-repo) form like any downstream repository’s would be.
The push: tags trigger stays in place for the two paths that have no dispatcher: the
hotfix tag and a human ad-hoc git push vX.Y.Z. Both are push-only, and their tag is
pushed with a workflow-triggering identity, so push: tags is their build path. The
machine paths never rely on it.
GoReleaser is the sole creator of cascade’s release object. Cascade’s own committed
orchestrate.yaml and manage-release composite action are generated in an own-repo
mode (cascade generate-workflow --own-repo, mirrored by cascade verify --own-repo so
the committed files are drift-locked to that variant): the rc cut’s manage-release step
cuts the candidate tag and stops there, without pre-creating a draft release, and does so
with the non-triggering GITHUB_TOKEN. GoReleaser, running inside Release, then creates
and publishes the one release for that tag. Nothing pre-creates a draft that GoReleaser
would duplicate, so no orphaned draft is left behind.
In own-repo mode the finalize job also runs the cascade binary built from the exact commit
under release, not a pinned published one. Cascade is self-hosting: the release tool is the
code being released, so a CLI capability added in a commit must be available in the same run
that ships it. Finalize already depends on the build-cli callback, which compiles cascade
from source; that job now uploads the binary as a cascade-cli artifact, and own-repo
finalize downloads it and puts it on PATH for the changelog and manage-release steps. A
downstream repository never does this: its finalize installs a pinned released cascade
through setup-cli, because a user runs a published binary and never builds cascade from
source. The from-source download is gated on build-cli succeeding; on a run where that
callback was skipped, own-repo finalize falls back to the same pinned install so the
changelog step still finds cascade on PATH.
This is not a manifest option. Own-repo mode is a CLI flag cascade passes only when
generating and verifying its own workflows, because cascade is the one repository that
self-publishes through GoReleaser; a downstream manifest has no way to reach it, and its
generated manage-release action carries no trace of the tag-only input at all. Every
other repository’s manage-release still pre-creates the draft its own release-build
workflow expects to find and publish.
A single-flight concurrency group named release, an idempotency check at the head of
the release job (.github/scripts/release-idempotency.sh), and a benign-422 belt after
GoReleaser remain as a defensive backstop. They protect the hotfix and human push paths
and any genuine race: the group holds the invariant that no two releases run at once, and
the idempotency check lets a queued duplicate finish green against an already-published
tag instead of driving GoReleaser into a 422 already_exists. On the machine paths, which
now fire once, these guards simply never engage.
The nightly-gated release
Section titled “The nightly-gated release”Cascade’s orchestrate workflow is dispatch-only, set through release_trigger: dispatch
in .github/manifest.yaml. A trunk merge no longer cuts a release candidate on its
own, which removes the per-merge candidate churn. The single gate that decides whether
to release is nightly-release.yaml.
It runs on a schedule (07:00 UTC daily, off-peak, after late-day merges settle) and
owns only two jobs, decide and dispatch. Everything from Release onward is the
existing chain, reused unchanged.
decide measures whether main has accumulated release-worthy changes since the last
published release:
- The diff base is the latest final release tag, matching
vX.Y.Zexactly so that a candidate (-rc.) or a leftover dry-run (-dryrun.) tag can never become the base. With no final release yet, or an unresolvable ref, it fails open and proceeds rather than silently skipping a real release. - It diffs the base against
origin/mainand classifies each changed path. Code and the shipped action surface (cmd/**,internal/**,go.mod,go.sum,.github/actions/**) count as release-worthy. The manifest counts only when its non-state subtree changed, so a routine state commit alone is not release-worthy. Documentation, Markdown, and similar paths never trigger a release on their own. - If nothing release-worthy changed, the run skips. A missed night just defers: the diff is always measured against the last release, so accumulated changes still release on the next run.
When decide says to proceed, dispatch dispatches orchestrate using the
CASCADE_STATE_TOKEN, so the candidate tag push fires Release and the chain continues.
Orchestrate cuts the candidate, Release publishes its assets, the full fleet fans out,
and auto-promote publishes the final version only on a green full run.
On-demand inputs: force and dry_run
Section titled “On-demand inputs: force and dry_run”nightly-release.yaml also runs on workflow_dispatch with two inputs for testing
the path on demand:
forcebypasses the change-since-last-release skip, so an unchanged main still cuts a candidate. It lives entirely insidedecideand changes nothing downstream.dry_runrehearses the whole path without publishing. The candidate is cut as avX.Y.Z-dryrun.Nprerelease instead of an-rc.candidate. The fleet’sresolvegate accepts-dryrun.tags, so a dry run fans out across the full fleet and writes its artifacts exactly like a real candidate. Auto-promote’s publish gate stays-rc.-only, so a dry-run tag can validate end to end yet is frozen out of publication. Thefull_runguard is a second, independent backstop.
A force plus dry_run dispatch therefore exercises every component of the real
path (the change gate bypass, the candidate cut, Release, the full fleet, the artifact
handoff, and the auto-promote wiring) while proving, by tag identity alone, that
nothing publishes.
Wayfinding
Section titled “Wayfinding”Prerequisite: Architecture for the system this release chain ships. Next: none. This is the end of the internals track.