Skip to content

Workflows

The framework generates two reusable workflows from your manifest: Orchestrate and Promote. Both are written by cascade generate-workflow.

Triggered on every merge to trunk. Handles the full CI/CD pipeline for the first environment in the promotion chain.

flowchart TD
    M["Merge to trunk"] --> S["Setup"] --> V["Validate"] --> B["Build"] --> D["Deploy"] --> F["Finalize"]
    S -.-> sn["Parse config, detect changes,<br/>compute version"]
    V -.-> vn["Optional pre-build validation"]
    B -.-> bn["Matrix: triggered builds only"]
    D -.-> dn["Matrix: triggered deploys,<br/>dependency-ordered"]
    F -.-> fn["Update state, generate changelog,<br/>draft pre-release"]

    classDef note fill:none,stroke:none,color:#8A929C;
    class sn,vn,bn,dn,fn note;

The orchestrate workflow is generated to fire on push to the trunk branch. You don’t need to wrap it. The generator emits the trigger directly:

# .github/workflows/orchestrate.yaml (generated)
on:
push:
branches: [master] # taken from config.trunk_branch

The orchestrate workflow has no manual inputs by default. It runs automatically on push.

OutputDescription
deployed_shaDeployed commit SHA
triggered_buildsJSON array of triggered builds
triggered_deploysJSON array of triggered deploys
versionCalculated RC version (e.g., v1.2.0-rc.0)
changelogGenerated changelog markdown
release_urlURL to the GitHub release
execution_planJSON execution plan with waves

The setup job determines what to build/deploy:

  1. Reads the manifest to get the last deployed SHA (base)
  2. Compares base to the current SHA (head)
  3. Matches changed files against triggers
  4. Builds an execution plan respecting depends_on

Example output:

{
"triggered_builds": ["app"],
"triggered_deploys": ["cdk", "services"],
"has_changes": true,
"execution_plan": {
"waves": [
{"name": "wave-1", "callbacks": ["app", "cdk"]},
{"name": "wave-2", "callbacks": ["services"]}
]
}
}

The version is computed from conventional commits between the previous release and the current SHA:

Commits since last releaseBump
feat!: or BREAKING CHANGE:major
feat:minor
fix: / perf:patch

The first environment receives an RC suffix: e.g., v1.2.0-rc.0. Each subsequent orchestrate run increments the RC counter.

Manual workflow to promote between environments.

flowchart TD
    M["Default mode<br/>(one step at a time)"] --> P["Preflight"] --> D["Deploy"] --> Pub["Publish"] --> F["Finalize"]
    P -.-> pn["Validate source/target, check ancestry,<br/>gate breaking changes"]
    D -.-> dn["Matrix: per-deploy with change detection"]
    Pub -.-> pubn["Only at prerelease to release boundary,<br/>if publish: configured"]
    F -.-> fn["Update state, publish release,<br/>dispatch Release workflow"]

    classDef note fill:none,stroke:none,color:#8A929C;
    class pn,dn,pubn,fn note;

A cascade mode (e.g., dev-to-prod) walks the chain step by step, running deploy/finalize for each intermediate environment, with the breaking-change gate enforced at the prerelease->release boundary.

# .github/workflows/promote.yaml (generated excerpt)
on:
workflow_dispatch:
inputs:
mode:
description: 'Promotion mode - default (sequential) or select a cascade target'
type: choice
required: true
options:
- default
- dev-to-test
- test-to-prod
- dev-to-prod
# ... all valid direct cascade targets
default: default
force:
description: 'Continue on failure (default mode only)'
type: boolean
default: false
allow_breaking_changes:
description: 'Required if promoting breaking changes past pre-release → release'
type: boolean
default: false
dry_run:
description: 'Dry run mode'
type: boolean
default: false
deploys:
description: 'Deploys to promote (comma-separated names or "all")'
type: string
default: 'all'
rollback_on_failure:
description: 'Revert successful deploys if any fails (atomic promotion)'
type: boolean
default: true
InputTypeDefaultDescription
modechoicedefaultdefault or a cascade target (e.g., dev-to-prod)
forcebooleanfalseContinue on failure (default mode only)
allow_breaking_changesbooleanfalseRequired to cross the prerelease->release boundary with breaking changes
dry_runbooleanfalsePreview without deploying
deploysstringallComma-separated deploy names or all
rollback_on_failurebooleantrueAtomic semantics: revert on failure
OutputDescription
source_shaSHA being promoted
target_envDestination environment
rollback_shaSHA to revert to on failure
deploys_to_runJSON array of deploys to run
external_deploys_to_runJSON array of external deploys to run
versionVersion applied to the target
changelogChangelog since the previous release
release_urlURL to the GitHub release

The promote workflow can run atomic promotions. If any deploy fails, the deploys that already succeeded are rolled back:

# Enabled by default
rollback_on_failure: true

When enabled:

  1. Preflight captures the target environment’s current SHA as rollback_sha
  2. If any deploy job fails, rollback jobs trigger for successful deploys
  3. Rollback jobs redeploy using the rollback_sha

The result is all-or-nothing promotion: either every deploy lands or none does.

Disable for non-atomic promotions:

rollback_on_failure: false

Use the deploys input to promote specific deploys:

deploys: "app,infra" # Only promote app and infra
deploys: "all" # Promote all (default)

The promote workflow uses diff-based detection:

  1. For each deployable, compare the target’s last deployed SHA with the source SHA
  2. Check whether trigger paths have changes
  3. Only run deploys with actual changes

This prevents unnecessary deploys (e.g., don’t redeploy CDK if only services changed).

The mode dropdown is generated from the configured environments list. The env names and the resulting <from>-to-<to> modes come from your own configuration, not from fixed names; roles are positional (last = release stage, second-to-last = prerelease).

Default mode advances the chain by one logical step (next env, or release/prod at the boundary).

Cascade modes are explicit from-to-to walks generated for every valid forward pair:

Mode (example)Behavior
dev-to-testPromote dev -> test
dev-to-uatCascade dev -> test -> uat (each step deployed and finalized)
dev-to-prodFull cascade through all environments + release
uat-to-prodPartial cascade from uat onward
test-to-prodStandard release

Cascade promotions are atomic per environment. The breaking-change gate runs at the prerelease->release boundary; pass allow_breaking_changes: true to proceed past it.

When the manifest contains a publish: callback, the promote workflow includes a publish step that runs once per configured build at the prerelease->release boundary. The framework reads artifact_id from the source environment’s build state and dispatches the publish workflow with:

build_name=<build name>
old_version=<RC version, e.g. v1.0.0-rc.2>
new_version=<final semver, e.g. v1.0.0>
sha=<source SHA>
artifact_id=<digest from build state>

The publish callback is responsible for the registry operation (retag, copy, sign).

For prod promotions:

  1. Get the latest semver tag (e.g., v1.2.3)
  2. Auto-increment based on conventional commits since that tag (major / minor / patch)
  3. Or use the version_override input for an explicit bump

The framework drops the RC suffix when crossing the prerelease->release boundary.

flowchart TD
    RF["<b>Roll forward first (default)</b><br/>fix merged to trunk; refused if not an ancestor of trunk tip"]
    RF -- "env must run base + fix only" --> IB["<b>env/&lt;env&gt;</b> integration branch<br/>created on demand at recorded state SHA"]
    IB --> CP["cherry-pick onto <b>hotfix/&lt;env&gt;/&lt;short-sha&gt;</b>"]
    CP -- "clean" --> PRclean["resolution PR · <b>cascade-hotfix</b><br/>state_token merge, gated by env checks"]
    CP -- "conflict" --> PRconf["resolution PR · <b>cascade-hotfix-conflict</b><br/>markers committed; human force-pushes head"]
    PRclean --> MERGE["on merge"]
    PRconf --> MERGE
    MERGE --> FIN["build -> deploy one env -> finalize<br/>vX.Y.Z-rc.N.hotfix.M · ref env/&lt;env&gt; · patches [fixes]"]
    FIN --> DIV["environment diverged<br/>other environments untouched"]
    DIV == "promote a trunk SHA containing the fix<br/>patch-containment guard refuses dropping it" ==> REJOIN["rejoin trunk<br/>divergence cleared · env/&lt;env&gt; deleted"]

A hotfix applies one or more trunk commits onto an environment that is pinned to an older trunk base, without dragging in the intervening commits. This is the case the standard promote flow cannot serve: promoting a pointer forward would advance the target environment past every commit between its base and the fix or set of fixes, which is exactly what an operator pinning that environment is trying to avoid.

The fix always lands on trunk first. cascade refuses to apply a commit that is not already an ancestor of trunk tip, so a hotfix never introduces a commit that exists only on a side branch. If the intervening commits between an environment’s base and the fix are acceptable, the simplest answer is to merge the fix to trunk and run a normal cascade promotion: the target environment advances to a trunk SHA and nothing diverges. Reach for the hotfix workflow only when the environment must run base + fix and nothing else.

When an environment genuinely needs to diverge, the hotfix is staged on a per-environment integration branch named env/<env> (for example env/test). The branch does not exist while an environment tracks trunk; it is created on demand at the environment’s recorded state SHA. The cherry-pick of the fix is staged on a working branch hotfix/<env>/<short-sha> whose base is env/<env>, and a resolution pull request is opened with base env/<env>.

While an environment is diverged its state carries three additional fields, all additive and absent for environments that track trunk:

state:
test:
sha: <merge SHA on env/test> # now possibly a non-trunk SHA
version: v1.4.0-rc.2.hotfix.1 # hotfix version segment
ref: env/test # the integration branch
base_sha: <trunk SHA> # the trunk anchor of the divergence
patches: [<sha of fix>, ...] # trunk commits applied on top

cascade status surfaces ref, base_sha, and patches only when they are set.

Before staging the cherry-pick, the hotfix plan reconciles the env/<env> branch against the environment’s recorded state SHA, the trunk anchor it sits at while the environment still tracks trunk. When the branch is absent it is created on demand at that SHA; when its tip already matches, it is left untouched. When the tip has drifted, the plan either self-heals the branch back to the recorded SHA or aborts fail-closed, never cherry-picking onto a base it cannot trust.

An interrupted hotfix run can leave an abandoned env/<env> branch whose tip leads the recorded SHA with no divergence recorded behind it. Left in place, a fresh hotfix would cherry-pick onto that stale tip and open a resolution pull request that can never merge cleanly, surfacing only as a merge-poll timeout. The self-heal force-resets such an orphan branch back to the recorded SHA and lets the hotfix proceed.

The reset is gated so it can never destroy live work. Divergence is recorded only at finalize, so a hotfix that is genuinely in flight (an open resolution pull request, real commits on env/<env>) also reports as not diverged while its branch legitimately leads the base. The plan therefore resets only when both conditions hold: the environment is not diverged, and a single-flight check has run against a real repository and found no open hotfix pull request. The single-flight check inspects open pull requests whose base is env/<env> and matches either the cascade-hotfix label (a clean resolution in progress) or the cascade-hotfix-conflict label (a human resolving a conflict). If either is open, the plan aborts and asks you to finalize the in-flight hotfix before re-dispatching.

Pass --repo owner/repo to enable the single-flight check through gh. Without it the check is skipped, so the self-heal cannot fire and any stale tip aborts the run rather than being reset. With --dry-run the reset is planned and reported but not performed.

A hotfix can carry a set of commits to a target environment higher in the chain. cascade elevates the set bottom-up across every environment from the one above the first up to and including the target, so each environment that must diverge ends up running its base plus the fixes. Per environment, any commit already present (an ancestor of that environment’s state SHA, or already in its patches) is skipped; an environment whose whole set is already present is a no-op and the chain moves on. Every commit applied to an environment is recorded in that environment’s patches, so the recorded set reflects every fix applied there, not just the first. The first environment is never a hotfix target: a fix reaches it by merging to trunk, not by hotfix.

A clean cherry-pick opens a pull request labeled cascade-hotfix and merges it as the configured state_token. The apply job polls the pull request until it is mergeable, so the required checks configured on env/<env> still gate the merge, and the pull request is the audit record even when no human touches it. The merge runs as state_token rather than the default GITHUB_TOKEN on purpose: a merge authored by GITHUB_TOKEN does not emit the pull_request close event, so the build, deploy, and finalize stages would never run and the diverged state would never be recorded. Configure state_token with a trigger-capable token (the same one used for state writes) to get the post-merge stages after an automated hotfix.

On conflict, the conflicted tree is committed with its conflict markers intact, the branch is pushed, and the pull request is opened labeled cascade-hotfix-conflict. Committing the markers makes the resolution pull request a real, checkout-able branch: the diff shows exactly where the conflict is, and a human resolves it locally by force-pushing the head branch.

On the chain path a conflict halts the elevation: the environments still pending are listed in the resolution pull request body, and the later environments are left untouched. After the resolution merges, re-engage the hotfix workflow targeting the same environment to resume the chain from where it stopped.

git fetch && git switch hotfix/<env>/<short-sha>
# resolve conflicts, then
git push --force-with-lease

The pushed resolution re-runs the checks, which unblock the merge. The pull request body also carries a machine-readable trailer block so the post-merge stages do not depend on branch-name parsing alone:

Cascade-Hotfix-Target: test
Cascade-Hotfix-Source: <fix SHA>
Cascade-Hotfix-Base: <base SHA>

When a conflict is resolved by hand, the resolution on env/<env> and the original fix on trunk can differ. Trunk’s version wins long term: the divergence is discarded, not merged back. If the manual resolution embodies a real improvement, it needs its own trunk pull request; merging env/<env> back to trunk is wrong because it would introduce merge commits into a history that every SHA comparison in cascade assumes moves forward.

The divergence ends the next time the environment receives a normal promotion. Promote preflight verifies that the incoming trunk SHA contains every recorded patch (the regression gate). On success the divergence fields are cleared, the env/<env> branch is deleted, and the hotfix tags and release objects for that base are cleaned up. Promotion is refused from a diverged environment, and promoting an older trunk SHA that would drop a recorded patch is blocked unless explicitly forced with a loud annotation.

cascade generate-workflow emits cascade-hotfix.yaml for any repository that declares two or more environments. With a single environment there is no intermediate target to hotfix onto, so nothing is emitted.

The workflow carries two triggers in one file:

  • workflow_dispatch with inputs commit (one or more trunk fix SHAs, comma-delimited), target_env (a choice over every configured environment except the first), pr_number (optional, to replay an existing resolution pull request), and dry_run.
  • pull_request on types: [closed] against branches: ['env/*'], with the post-merge stages gated on the pull request having merged and carrying the cascade-hotfix label.

Its jobs:

JobTriggerRole
plandispatchFetch env branches and tags, run cascade hotfix plan, surface branch-protection suggestions as ::notice:: lines
applydispatch (not dry-run)Cherry-pick the set onto each environment bottom-up; clean picks open the resolution pull request (polled until mergeable, then merged as state_token), a conflict opens the labeled resolution pull request and halts the chain
checkopen pull request to env/*Validate the manifest while the hotfix pull request is open
buildmerged hotfixBuild the merge SHA, since a cherry-picked commit has no prebuilt artifact
deploymerged hotfixDeploy to the target environment, paired with a rollback job mirroring the promote workflow
finalizeall deploys succeedRun cascade hotfix finalize to write the diverged state, tag, and release

Prod is a valid hotfix target. The deploy job binds to the GitHub environment: of the target environment, so organization protection rules (manual approval, required reviewers) apply to the hotfix deploy exactly as they do to a normal promotion. This is one mechanism, not a separate prod path.

Branch protection on env/* is the operator’s responsibility: cascade never creates protection rules itself, because it does not assume an admin token. When no required status checks are configured on the target env/* branch, the workflow warns rather than blocks, and the plan verb prints ready-to-run gh and gh api command suggestions an operator can paste to put the protections in place.

For the trunk branch, cascade branch-protection emits the full JSON body to PUT to the branches protection API in one step, with only the safe-to-require Setup and Finalize contexts pre-filled. See branch-protection.

The rollback_sha output in the generated workflow is a disclosed placeholder today: the deploy and rollback jobs mirror the promote workflow’s shape, and the rollback path activates once a CLI output supplies the prior SHA.

cascade generates a standalone cascade-rollback.yaml workflow whenever the manifest declares at least two environments. It re-deploys a prior version or SHA to a target environment, defaulting to the previous version (N-1). A read-only preflight resolves the target, the deploy stage re-runs the configured deploy callbacks keyed on the resolved SHA, and finalize writes the rolled-back state back to trunk.

Rollback covers the promoted environments only. The first environment tracks trunk and is never promoted into, so it keeps no deploy history to roll back to: roll it forward by reverting the offending change on the trunk branch instead. The workflow dropdown offers only the promoted environments, and a rollback aimed at the first environment fails fast with that guidance.

By default the workflow is triggered by manual dispatch only (workflow_dispatch).

To let an external system (an alerting or incident pipeline) drive the same rollback automatically, opt into a repository_dispatch trigger:

rollback:
repository_dispatch:
types: [rollback-requested]

When set, the generated rollback workflow gains a repository_dispatch trigger alongside the unchanged workflow_dispatch, and every rollback parameter read coalesces the manual input with the dispatch payload:

ENVIRONMENT: ${{ github.event.inputs.environment || github.event.client_payload.environment }}

so both trigger paths resolve the same target. When the block is absent, the rollback workflow is byte-for-byte unchanged (manual dispatch only). At least one event type is required, and each type may contain only letters, digits, dots, hyphens, and underscores.

repository_dispatch carries no inputs, so an external caller supplies the rollback parameters in client_payload. The keys map name-for-name onto the manual workflow_dispatch inputs:

client_payload keyRollback parameterMeaning
environmentenvironmentEnvironment to roll back (required)
targettargetPrior version or SHA; omit for the previous version (N-1)
deployabledeployableLimit the rollback to one deployable; omit for the whole environment
dry_rundry_runWhen "true", resolve and print without deploying

An external system fires the rollback with a single dispatches API call (substitute your own org and repo):

Terminal window
gh api repos/my-org/my-repo/dispatches \
-f event_type=rollback-requested \
-F 'client_payload[environment]=prod' \
-F 'client_payload[target]=v1.4.2'

The event type must match one of the configured types. Because the trigger fires the same N-1 rollback the manual path performs, the dispatching system needs no rollback logic of its own.

Generated workflows include the necessary permissions:

permissions:
contents: write # Push state, create tags
actions: write # Dispatch the Release workflow from finalize
packages: write # Optional: only if your callbacks publish to GHCR

Every deploy is a reusable workflow, so set the environment: key on the job inside your callback. cascade passes the target environment name as the environment input and cannot set environment: on the caller job it generates, because GitHub Actions disallows that key on a uses: job:

jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }} # GitHub enforces approvals

cascade prints a generate-time note when gha_environment is configured, reminding you to declare environment: inside the reusable workflow.

Each workflow uses concurrency groups to prevent conflicts:

# Orchestrate - per branch
concurrency:
group: orchestrate-${{ github.ref }}
cancel-in-progress: false
# Promote - per source environment
concurrency:
group: promote-${{ inputs.mode }}
cancel-in-progress: false

Both workflows support dry_run: true:

  • Detects changes normally
  • Generates the execution plan
  • Skips actual deployments (callbacks check inputs.dry_run)
  • Does not update state
  • Does not create or publish releases

Use dry run to preview what would happen.

Enable trace-level logging by setting TRACE=true in the environment, or invoke the CLI with --trace:

Terminal window
cascade --trace orchestrate setup --environment dev

Trace logs include:

  • Full change detection results
  • Dependency resolution steps
  • Callback input/output details
  • State operations