diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a4e839d3..e778f8cf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -55,6 +55,18 @@ cascade owns the third-party action pins it emits into generated workflows, and
- Generated files are targets, never sources: a pin (or any other value) is read from the manifest and written into generated output, never read back out of a generated file. This keeps generation a pure, offline function of the manifest, which is what makes a regenerate reproducible and a diff meaningful.
- cascade's own self-heal companion is generated, not hand-written. `.github/workflows/pin-reconcile.yaml` is produced by the same reconcile generator that emits a downstream user's companion, in its own-repo variant, and is drift-locked byte-for-byte by a test so a hand-edit fails the suite. The own-repo variant differs from the user emission in exactly three ways: it installs the latest non-prerelease cascade release (never an rc or a draft, so cascade's own CI cannot self-install a prerelease), it scans both the workflow and composite-action trees for a moved pin, and it commits the regenerated workflows alongside the updated `action_pins.yaml`. Change the generator and regenerate the file; never edit the workflow by hand.
+## Documentation quality
+
+A change that alters behavior, CLI surface, flags, config or manifest fields, generated output, or the release flow updates the affected docs in the same pull request: the docs site under `docs/src/content/docs/`, the root `README.md`, and any other affected Markdown file. The docs site follows these rules:
+
+- Every page is typed to one Diataxis mode (tutorial, how-to, reference, or explanation) and stays in that mode.
+- Depth is layered: the common path a typical reader needs comes first; edge cases, the full field surface, and advanced options move into a clearly labeled later section.
+- Each concept is single-sourced on exactly one page. Every other page that touches it links there instead of restating it.
+- A manifest field's emission status ("emitted", "validated-only", or "reserved") is stated accurately wherever the field is documented. Get this wrong and the reference has failed its one job.
+- Every page carries a prerequisite and next-step link, so a reader always knows what to read before and after.
+
+Stale docs fail review.
+
## Reporting bugs
Open an issue with the manifest config, the generated workflow (if relevant), and what you expected versus what happened. A minimal reproduction helps a lot.
diff --git a/README.md b/README.md
index 2f98acdd..4995bb9b 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
-
@@ -36,128 +35,17 @@
---
-## How it works
-
-The **manifest** (`.github/manifest.yaml`) is the single source of truth:
-
-- It holds both the pipeline configuration and the live deployment state for every environment.
-- You run `cascade generate-workflow` once.
-- After that, the generated workflows own their own execution.
-
-```mermaid
-%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif, system-ui, sans-serif','primaryColor':'#0E8B82','primaryBorderColor':'#36D0C4','primaryTextColor':'#F4FBFA','lineColor':'#1F9B92','clusterBkg':'transparent','clusterBorder':'#36D0C4','tertiaryColor':'#B87333'}}}%%
-flowchart TD
- M[".github/manifest.yaml config + live state"] --> G["cascade generate-workflow"]
- G --> WF["Generated GitHub Actions orchestrate.yaml + promote.yaml"]
-
- WF -- "merge to trunk" --> O
-
- subgraph O["Orchestrate (on merge)"]
- direction LR
- O1["Setup"] --> O2["Validate"] --> O3["Build"] --> O4["Deploy first env"] --> O5["Finalize"]
- end
-
- O -- "workflow_dispatch · same artifacts, never rebuilt" --> P
-
- subgraph P["Promote (cascade through environments)"]
- direction LR
- dev["dev"] --> test["test"] --> staging["staging"] --> prod["prod"]
- end
-
- P --> R
-
- subgraph R["Release lifecycle"]
- direction LR
- draft["draft"] --> pre["prerelease"] --> pub["published RC tags cleaned"]
- end
-
- classDef accent fill:#B87333,stroke:#E8702A,color:#FFF7F0;
- class M accent;
-```
-
----
-
-## Cross-repo artifact tracking
-
-A primary repo can own the environment chain for artifacts that are built and versioned in other repos:
-
-- Each external repo dispatches the primary's generated `external-update.yaml`.
-- That workflow writes `{sha, version}` into `state..external.` of the one shared manifest.
-- Concurrent updates serialize on that manifest, then the primary cascades every source through its own environments.
-- A callback can also pull in an external repo's workflow synchronously via `uses:` during the primary's run.
-
-```mermaid
-%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif, system-ui, sans-serif','primaryColor':'#0E8B82','primaryBorderColor':'#36D0C4','primaryTextColor':'#F4FBFA','lineColor':'#1F9B92','clusterBkg':'transparent','clusterBorder':'#36D0C4','tertiaryColor':'#B87333'}}}%%
-flowchart TD
- subgraph EXT["External artifact repos"]
- direction LR
- A["artifact-a builds its own artifact"]
- B["artifact-b builds its own artifact"]
- end
-
- A -- "workflow_dispatch source_repo · deploy_name · environment sha · version · artifacts" --> EU
- B -- "workflow_dispatch source_repo · deploy_name · environment sha · version · artifacts" --> EU
-
- subgraph PRIMARY["Primary repo"]
- direction TB
- EU["external-update.yaml cascade external update"]
- EU -- "writes {sha, version}" --> ST[".github/manifest.yaml state.<env>.external.<name> concurrent updates serialize"]
- ST --> PR
- subgraph PR["Promote (cascade through environments)"]
- direction LR
- dev["dev"] --> test["test"] --> staging["staging"] --> prod["prod"]
- end
- end
-
- CB["Primary build / deploy callback"] -. "sync uses: org/artifact-repo/.github/workflows/<name>.yaml@ref" .-> SYNC["External workflow invoked inline"]
-
- classDef accent fill:#B87333,stroke:#E8702A,color:#FFF7F0;
- class ST accent;
-```
-
----
+cascade is a compiler, not a control plane. You describe your environments, builds, deploys, and release policy in one manifest. cascade compiles that manifest into GitHub Actions workflows and then gets out of the way: the generated workflows run on GitHub's own runners, with no external service, agent, or daemon watching your repository.
-## Hotfix any environment
-
-Most pipelines can only hotfix the tip, which in practice means production. cascade hotfixes **any** environment:
-
-- It stages the fix or set of fixes on a per-environment integration branch.
-- A hotfix can carry a set of commits and elevate them across the chain up to the target environment.
-- It deploys the diverged environments with a clean `-rc.N.hotfix.M` version.
-- It rejoins trunk the next time a trunk SHA that already contains the fixes is promoted.
-
-The example below lands a fix on **staging** while dev, test, and prod stay exactly where they are.
-
-```mermaid
-%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif, system-ui, sans-serif','primaryColor':'#0E8B82','primaryBorderColor':'#36D0C4','primaryTextColor':'#F4FBFA','lineColor':'#1F9B92','clusterBkg':'transparent','clusterBorder':'#36D0C4','tertiaryColor':'#B87333'}}}%%
-flowchart TD
- T["trunk tip fix already merged (roll forward first)"]
-
- subgraph LADDER["Environments"]
- direction LR
- dev["dev"] --> test["test"] --> staging["staging"] --> prod["prod"]
- end
-
- T -- "cherry-pick fix onto env/staging at staging's recorded base_sha" --> CP["hotfix/staging/<short-sha> base + fix, nothing else"]
- CP --> RPR["resolution PR (base env/staging) cascade-hotfix · auto-merge on env checks"]
- RPR -- "on merge: build -> deploy staging only -> finalize" --> DS
-
- subgraph DS["staging diverged"]
- direction TB
- SV["v1.4.0-rc.2.hotfix.1 ref: env/staging base_sha: trunk SHA · patches: [fix]"]
- end
-
- DS -. "targets staging" .-> staging
+## How it works
- DS == "later promotion of a trunk SHA containing the fix clears divergence (patch-containment guard)" ==> staging
+The manifest (`.github/manifest.yaml`) holds both the pipeline configuration and the live deployment state for every environment. You run `cascade generate-workflow` once to compile it into GitHub Actions workflows, and commit those alongside your code. From then on the generated workflows own their own execution: a merge to trunk builds and deploys to the first environment, and a `workflow_dispatch` promotes the same built artifact through the rest of the chain without rebuilding it.
- classDef accent fill:#B87333,stroke:#E8702A,color:#FFF7F0;
- class SV accent;
-```
+Read [How Cascade works](https://stablekernel.github.io/cascade/start/how-it-works/) for the full mental model, including the release boundary and the hotfix and rollback off-ramps.
---
-## Is cascade right for your repo?
+## Is cascade for you?
cascade earns its keep when you promote a built artifact through a chain of environments. It is a strong fit when most of these hold:
@@ -168,222 +56,129 @@ cascade earns its keep when you promote a built artifact through a chain of envi
It is likely overkill for a single environment with a plain build-and-release on push. A repo with no deployments at all is a different case: the no-environment mode is a supported shape that still gives you conventional-commit versioning and releases.
-**Not trunk-based yet?** cascade promotes *from trunk*:
-
-- You merge to one trunk branch and cascade promotes that line through your environments.
-- If you run release branches or a GitFlow model today, adopting cascade means moving promotion onto a trunk-based flow.
-- That is a deliberate shift, and cascade is a practical vehicle for it: your existing build and deploy steps become reusable-workflow callbacks, and cascade takes over the promotion, state, and release wiring on top of them.
-
-### What adopting looks like
-
-Adoption involves a few steps:
-
-- Keep the build and deploy logic you already have, and wrap each as a `workflow_call` reusable workflow.
-- Describe your environments and callbacks in the manifest.
-- Let cascade generate the orchestration.
-- Keep the tooling you rely on: point cascade's changelog or release step at your own workflow, or switch it off, while cascade owns the promotion cascade.
-
-See the **[Adoption guide](https://stablekernel.github.io/cascade/adoption/)** for the full walkthrough on migrating an existing pipeline and wiring in tooling you already use. For reference: the [Getting Started guide](https://stablekernel.github.io/cascade/getting-started/), the [Callback Contract](https://stablekernel.github.io/cascade/callback-contract/) for the inputs cascade passes your workflows, and the [hardening guide](https://stablekernel.github.io/cascade/security/hardening/) for the GitHub setup (branch protection, environments, scoped tokens).
+Already running a pipeline? See the [adoption guide](https://stablekernel.github.io/cascade/guides/adopt/) for migrating without a rewrite.
---
-## Quick start
-
-### 1. Install the CLI
+## Quickstart
```bash
go install github.com/stablekernel/cascade/cmd/cascade@latest
-# or pin a specific version:
-go install github.com/stablekernel/cascade/cmd/cascade@v0.1.0
```
-> **Fastest path:** run `cascade init` to scaffold a working starter (manifest with a pinned `cli_version`, build and deploy callback stubs, a `CODEOWNERS`, and an AWS OIDC trust-policy example), then edit two values and push. Pick a shape with `--topology` (`no-env` for a CLI/library, or `two-env`/`three-env`/`four-env` for a promotion pipeline). The scaffold is a verifying starter: `cascade init` then `cascade generate-workflow` leaves `cascade verify` clean. The steps below build the same manifest by hand if you prefer.
+See [Getting started](https://stablekernel.github.io/cascade/start/getting-started/) for the pinned-version install and the `setup-cli` action, both of which most teams should use instead of a bare `@latest` install.
-### 2. Create the manifest
+Write a manifest, write your build and deploy callbacks, then generate. Callbacks must exist first: the generator reads their `workflow_call` outputs to wire the rest.
```yaml
# .github/manifest.yaml
ci:
config:
+ schema_version: 1
trunk_branch: main
- cli_version: v0.1.0
-
- environments: [dev, test, uat, prod]
-
+ cli_version: v0.9.1
+ environments: [dev, staging, prod]
builds:
- name: app
workflow: .github/workflows/build-app.yaml
- triggers: [src/**, go.mod]
-
- deploys:
- - name: infra
- workflow: .github/workflows/deploy-infra.yaml
- triggers: [cdk/**]
- - name: app
- workflow: .github/workflows/deploy-app.yaml
- depends_on: [app] # waits for build-app to succeed
-
- changelog:
- contributors: true
```
-### 3. Generate the workflows
-
```bash
+# 1. Write .github/manifest.yaml (above)
+# 2. Write your build/deploy callback workflows (they must exist first)
+# 3. Generate the orchestration workflows
cascade generate-workflow --config .github/manifest.yaml
-# Creates: .github/workflows/orchestrate.yaml
-# .github/workflows/promote.yaml
-```
-
-Commit the generated files. cascade re-generates them whenever you update the manifest; the `-f` flag overwrites in place.
-
-### 4. Write your callbacks
-
-cascade calls your workflows via `workflow_call` and passes standard inputs. You own the build and deploy logic.
-
-```yaml
-# .github/workflows/build-app.yaml
-on:
- workflow_call:
- inputs:
- environment:
- type: string
- required: true
- sha:
- type: string
- required: true
- outputs:
- artifact_id:
- description: 'Immutable artifact identifier (e.g., Docker image digest)'
- value: ${{ jobs.build.outputs.artifact_id }}
+# 4. Commit everything and push to trunk to run the first pipeline
```
-cascade is a metadata courier. You construct the registry and deploy operations yourself.
+The full walkthrough, including `cascade init` scaffolding and the four topology shapes, lives in [Getting started](https://stablekernel.github.io/cascade/start/getting-started/).
---
-## Capabilities
+## What cascade generates
-cascade generates workflows that handle the orchestration layer. Your callback workflows handle the domain logic. The manifest gives you control over:
-
-- **Change detection**: builds and deploys run only when their declared `triggers` match changed paths.
-- **Dependency ordering**: `depends_on` chains builds and deploys in the right order.
-- **Matrix builds**: fan out a single build over a matrix of inputs.
-- **Per-job runner selection**: set `runs_on` at the config or per-build/deploy level.
-- **Concurrency control**: configurable group and cancel-in-progress on orchestrate, promote, release, and external-update workflows.
-- **Extra triggers**: attach `schedule`, `repository_dispatch`, `workflow_run`, and `merge_group` events to orchestration.
-- **Dispatch inputs**: expose operator-facing manual-run inputs on the generated `workflow_dispatch`.
-- **PR plan preview**: a comment on each PR shows which builds and deploys would run.
-- **Merge queue lane**: a dedicated gate job runs before merge to protect trunk.
-- **Action pinning**: `pin_mode: sha` emits pinned SHA references for all cascade-managed action calls. Override individual actions via `action_pins`. cascade owns the action pins in the workflows it generates, and the opt-in `reconcile` companion reconciles an external bump (for example a merged Dependabot update) back into the manifest so ownership stays in one place.
-- **Breaking-change gate**: `feat!:` or `BREAKING CHANGE:` commits block the prerelease-to-release boundary unless you override them.
-- **Artifact passing**: the `artifact_id` output from build callbacks is stored in state and forwarded to deploys and the publish callback.
-- **Publish callback**: once a release is published, a separate workflow call lets you retag RC artifacts in your registry.
-- **Schema version enforcement**: every CLI invocation checks `schema_version` on the manifest and rejects incompatible manifests with a clear error.
-
-For a no-environment project (library or CLI), omit `environments` entirely. Commits produce RC pre-releases; a `promote` dispatch publishes the final release.
-
----
+A `generate-workflow` run emits, unconditionally:
-## Promotion
-
-
-
-Promotions are triggered via `workflow_dispatch` on the generated `promote.yaml`.
-
-| Mode | Behavior |
+| File | Purpose |
|---|---|
-| `default` | Advance the chain one logical step |
-| `dev-to-test` | Promote dev to test |
-| `dev-to-uat` | Cascade: dev → test → uat (all intermediates updated atomically) |
-| `dev-to-prod` | Full cascade through all environments |
-| `uat-to-prod` | Partial cascade from uat onward |
-
-These modes are generated from your configured environment names (`dev`, `test`, `uat`, `prod` shown here as an example); roles are positional, with the last environment as the release stage.
+| `.github/workflows/orchestrate.yaml` | Runs on merge to trunk: validate, build, deploy to the first environment, finalize state. |
+| `.github/workflows/promote.yaml` | Manually dispatched: cascades the same built artifact through the rest of the environment chain. |
+| `.github/workflows/cascade-hotfix.yaml` | Patches a single environment out of band without touching the others. |
+| `.github/workflows/cascade-rollback.yaml` | Rolls an environment back to its previous deployed version. |
+| `.github/actions/manage-release/action.yaml` | Composite action that creates, updates, and publishes the GitHub release. |
-The same artifacts built on the first merge are promoted through the chain; nothing is rebuilt.
+Opt-in companions (drift-check, PR-preview, pin-reconcile) are emitted only when their manifest block is present. See [Generated workflows](https://stablekernel.github.io/cascade/reference/generated-workflows/) for the full anatomy of each file.
---
-## State
+## Capabilities
-The manifest tracks deployment state automatically. The `state:` section is managed by cascade; do not edit it by hand.
+| Capability | What it does |
+|---|---|
+| Change detection | Builds and deploys run only when their declared `triggers` match changed paths. |
+| Dependency ordering | `depends_on` and `optional_depends_on` chain builds and deploys in the right order. |
+| Matrix builds | Fan a single build out over a matrix of `dimensions`, with `max_parallel` and `fail_fast`. |
+| Concurrency control | Configurable group and `cancel_in_progress` on orchestrate, promote, hotfix, rollback, release, and external-update workflows. |
+| Extra triggers | Attach `schedule`, `repository_dispatch`, `workflow_run`, and `merge_group` events to orchestration. |
+| Least-privilege permissions | Per-callback `permissions:` blocks scope each caller job, including OIDC `id-token: write`. |
+| Action pinning | `pin_mode: tag` (default) or `sha`, with an embedded action-pins manifest and an opt-in reconcile companion. |
+| PR plan preview | An opt-in comment on each PR shows which builds and deploys would run. |
+| Breaking-change gate | `feat!:` or `BREAKING CHANGE:` commits block the prerelease-to-release boundary unless overridden. |
+| Artifact passing | The `artifact_id` output from a build is stored in state and forwarded to deploys and publish. |
+| GitHub Environments | The `environments` command emits per-environment config (`required_reviewers`, `wait_timer`, `branch_policy`) for you to apply. |
+| Schema enforcement | Every CLI invocation checks `schema_version` and rejects incompatible manifests with a clear error. |
+
+A manifest that puts a few of these to work: `web` builds only after `api`, and each build and deploy runs only when its `triggers` match the changed paths.
```yaml
+# .github/manifest.yaml
ci:
- state:
- dev:
- sha: abc123
- version: v1.2.0-rc.3
- committed_at: "2025-01-15T10:30:00Z"
- committed_by: github-actions[bot]
- builds:
- app:
- sha: abc123
- artifact_id: sha256:def456
- built_at: "2025-01-15T10:30:00Z"
- deploys:
- infra:
- sha: abc123
- deployed_at: "2025-01-15T10:31:00Z"
- release:
- sha: abc000
- version: v1.1.0
- latest_release:
- version: v1.1.0
- sha: abc000
+ config:
+ schema_version: 1
+ trunk_branch: main
+ cli_version: v0.9.1
+ environments: [dev, staging, prod]
+ builds:
+ - name: api
+ workflow: .github/workflows/build-api.yaml
+ triggers: ["api/**"]
+ - name: web
+ workflow: .github/workflows/build-web.yaml
+ triggers: ["web/**"]
+ depends_on: [api]
+ deploys:
+ - name: services
+ workflow: .github/workflows/deploy.yaml
+ triggers: ["api/**", "web/**"]
```
----
-
-## CLI reference
-
-| Command | Description |
-|---|---|
-| `generate-workflow` | Generate `orchestrate.yaml` and `promote.yaml` |
-| `orchestrate setup` | Detect changes, compute version, plan execution |
-| `orchestrate finalize` | Update state, manage release, commit manifest |
-| `promote preflight` | Validate, compute promotions, check breaking changes |
-| `promote finalize` | Update state after promotion deploys complete |
-| `generate-changelog` | Create changelog from conventional commits |
-| `manage-release` | Create, update, or publish GitHub releases |
-| `next-version` | Calculate next semantic version |
-| `detect-changes` | Determine which builds/deploys a file change triggers |
-| `parse-config` | Validate and print the parsed manifest (with schema warnings) |
-| `reset` | Wipe releases and state (for testing or a fresh start) |
-
-Full flag reference: [CLI reference](https://stablekernel.github.io/cascade/cli-reference/).
+Full field-by-field detail lives in the [manifest reference](https://stablekernel.github.io/cascade/reference/manifest/).
---
## Documentation
-| Document | Description |
+| Start here | |
|---|---|
-| [Stage Graph](https://stablekernel.github.io/cascade/stage-graph/) | The mental model. Start here to see how the stages fit together |
-| [Getting Started](https://stablekernel.github.io/cascade/getting-started/) | Step-by-step setup guide |
-| [Configuration](https://stablekernel.github.io/cascade/configuration/) | Full manifest reference |
-| [Workflows](https://stablekernel.github.io/cascade/workflows/) | Orchestrate and Promote explained |
-| [CLI Reference](https://stablekernel.github.io/cascade/cli-reference/) | All commands and flags |
-| [Callback Contract](https://stablekernel.github.io/cascade/callback-contract/) | How to write build/deploy/publish workflows |
-| [Architecture](https://stablekernel.github.io/cascade/architecture/) | System design and internals |
-| [Schema Versioning](https://stablekernel.github.io/cascade/versioning/) | Compatibility policy and migration guide |
-
----
-
-## Roadmap to stable
+| [Why Cascade](https://stablekernel.github.io/cascade/start/why-cascade/) | What cascade is, when to use it, and how it compares to adjacent tools. |
+| [How Cascade works](https://stablekernel.github.io/cascade/start/how-it-works/) | The mental model: trunk, environment chain, release boundary. |
+| [Getting started](https://stablekernel.github.io/cascade/start/getting-started/) | Install, scaffold or hand-write a manifest, and run your first pipeline. |
-cascade is functional and self-hosted; its own releases page shows the full pipeline running end to end. The remaining work before the v1.0.0 schema freeze falls into two areas:
-
-- **Schema coverage.** A few GitHub Actions capabilities are modeled in the manifest shape but not yet emitted by the generator: environment gates, OIDC token configuration, and per-environment runner overrides. These sit on the direct path to v1.0.0.
-- **Hardening.** This covers schema version enforcement (shipped), compatibility docs ([schema versioning](https://stablekernel.github.io/cascade/versioning/)), and more e2e coverage. The added tests confirm that the generated workflows behave correctly under edge cases such as empty builds, cross-repo coordination, and rollback to N-1.
-
-Schema stability:
+| Task guides | |
+|---|---|
+| [Adopt an existing pipeline](https://stablekernel.github.io/cascade/guides/adopt/) | Migrate without a rewrite; coexist with tools you already use. |
+| [Promote a release](https://stablekernel.github.io/cascade/guides/promote/) | Trigger and watch a promotion. |
+| [Run a hotfix](https://stablekernel.github.io/cascade/guides/hotfix/) | Patch one environment without touching the others. |
+| [Roll back an environment](https://stablekernel.github.io/cascade/guides/rollback/) | Revert an environment to its previous version. |
-- The manifest schema field shapes were frozen in v0.1.0 as the v1 contract baseline.
-- Minor versions between now and v1.0.0 may add new optional fields; no existing fields will be removed or renamed before v1.0.0.
+| Reference | |
+|---|---|
+| [Manifest](https://stablekernel.github.io/cascade/reference/manifest/) | Every manifest field, its emission status, and its default. |
+| [CLI](https://stablekernel.github.io/cascade/reference/cli/) | Every command, flag, environment variable, and exit code. |
+| [Callback contract](https://stablekernel.github.io/cascade/reference/callbacks/) | The inputs and outputs your build/deploy/publish workflows exchange with cascade. |
+| [Generated workflows](https://stablekernel.github.io/cascade/reference/generated-workflows/) | The exact file set and the anatomy of each generated workflow. |
-Open work is tracked in [GitHub Issues](https://github.com/stablekernel/cascade/issues).
+See the [full sidebar](https://stablekernel.github.io/cascade/) for the rest, including security and internals.
---
diff --git a/SECURITY.md b/SECURITY.md
index 57b65eea..e88d3dc3 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -9,7 +9,7 @@
The latest release line is the active one. Only the most recent release receives security patches. Upgrade to the latest release to stay covered.
-The schema-version compatibility policy (which CLI versions read which manifest versions) is documented separately in [versioning and schema compatibility](https://stablekernel.github.io/cascade/versioning/).
+The schema-version compatibility policy (which CLI versions read which manifest versions) is documented separately in [versioning and schema compatibility](https://stablekernel.github.io/cascade/reference/versioning/).
## Reporting a vulnerability
@@ -33,4 +33,4 @@ We follow [coordinated disclosure](https://en.wikipedia.org/wiki/Coordinated_vul
Cascade is a build-time tool that generates GitHub Actions workflows you commit and review in your own repository. The generated workflows run under your own runners, branch protection, and environment gates, and cross-repo coordination uses a same-organization, shared-token model where a dispatch token you provision is the trust boundary. Deploying cascade safely is therefore a shared responsibility between cascade and your organization's GitHub and cloud configuration.
-See the [security and hardening guide](https://stablekernel.github.io/cascade/security/hardening/) for the full model and a step-by-step hardening checklist.
+See the [security and hardening guide](https://stablekernel.github.io/cascade/security/) for the full model and a step-by-step hardening checklist.
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 6bb6a88b..36af4f12 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -68,27 +68,40 @@ export default defineConfig({
attrs: { name: 'twitter:card', content: 'summary_large_image' },
},
],
- // Sidebar order mirrors the previous MkDocs nav, with the decision-aid
- // "Why Cascade" page first so readers can self-qualify before onboarding.
+ // Sidebar mirrors the journey: orient (Why Cascade), start (mental model +
+ // tutorial), task guides (operator how-tos), reference (exhaustive lookup),
+ // security, then internals (contributor depth). Every label pairs with its
+ // page's `title` frontmatter.
sidebar: [
- { label: 'Why Cascade', link: '/comparison/' },
- { label: 'Stage Graph', link: '/stage-graph/' },
- { label: 'Getting Started', link: '/getting-started/' },
- { label: 'Adoption Guide', link: '/adoption/' },
- { label: 'Manifest Reference', link: '/configuration/' },
- { label: 'Callback Contract', link: '/callback-contract/' },
- {
- label: 'Workflows',
- items: [{ label: 'Overview', link: '/workflows/' }],
- },
- { label: 'CLI Reference', link: '/cli-reference/' },
- { label: 'Local Simulation', link: '/simulate/' },
- { label: 'Architecture', link: '/architecture/' },
- { label: 'How it is tested', link: '/testing/' },
- { label: 'Feature coverage matrix', link: '/coverage-matrix/' },
- { label: 'Release orchestration', link: '/release-orchestration/' },
- { label: 'Security & Hardening', link: '/security/hardening/' },
- { label: 'Versioning & Schema', link: '/versioning/' },
+ { label: 'Why Cascade', link: '/start/why-cascade/' },
+ { label: 'Start here', items: [
+ { label: 'How Cascade works', link: '/start/how-it-works/' },
+ { label: 'Getting started', link: '/start/getting-started/' },
+ ]},
+ { label: 'Task guides', items: [
+ { label: 'Adopt an existing pipeline', link: '/guides/adopt/' },
+ { label: 'Add or change environments', link: '/guides/environments/' },
+ { label: 'Promote a release', link: '/guides/promote/' },
+ { label: 'Run a hotfix', link: '/guides/hotfix/' },
+ { label: 'Roll back an environment', link: '/guides/rollback/' },
+ { label: 'Simulate and verify', link: '/guides/simulate-and-verify/' },
+ { label: 'Coordinate multiple repos', link: '/guides/multi-repo/' },
+ { label: 'Visualize the pipeline', link: '/guides/visualize/' },
+ ]},
+ { label: 'Reference', items: [
+ { label: 'Manifest', link: '/reference/manifest/' },
+ { label: 'Callback contract', link: '/reference/callbacks/' },
+ { label: 'CLI', link: '/reference/cli/' },
+ { label: 'Generated workflows', link: '/reference/generated-workflows/' },
+ { label: 'Versioning & schema', link: '/reference/versioning/' },
+ ]},
+ { label: 'Security & hardening', link: '/security/' },
+ { label: 'Internals', items: [
+ { label: 'Architecture', link: '/internals/architecture/' },
+ { label: 'How Cascade is tested', link: '/internals/testing/' },
+ { label: 'Feature coverage matrix', link: '/internals/coverage-matrix/' },
+ { label: 'Release orchestration', link: '/internals/release-orchestration/' },
+ ]},
],
}),
],
diff --git a/docs/src/content/docs/adoption.md b/docs/src/content/docs/adoption.md
deleted file mode 100644
index 7ec361f8..00000000
--- a/docs/src/content/docs/adoption.md
+++ /dev/null
@@ -1,217 +0,0 @@
----
-title: Adoption Guide
-description: A journey-oriented guide to adopting cascade. It covers the mental model, building a pipeline from scratch, migrating an existing one, and wiring tools like release-please and goreleaser into reusable-workflow callbacks.
----
-
-This guide ties the reference docs together into one path: how to think about cascade, how to build a pipeline from nothing, and how to migrate an existing pipeline (and existing tools) onto it. If you have never run cascade, start with [Getting Started](/cascade/getting-started/) for installation, then come back here for the bigger picture.
-
-## Mental model
-
-cascade owns **orchestration**. It generates the GitHub Actions workflows that promote a commit through your environments, hold per-environment state, pin each promotion to a specific SHA, enforce a breaking-change gate at the release boundary, and provide hotfix, rollback, and cross-repo artifact tracking. It derives versions and changelogs from your commit history.
-
-You own **the verbs**. Build, deploy, validate, and publish are *your* logic, supplied as reusable workflows that cascade calls with a fixed input contract. cascade never runs your scripts inline; it calls a `workflow_call` reusable workflow you point at. That is the central rule of adoption: **every callback must be a reusable workflow.** Inline `run:` and `shell:` callbacks were removed.
-
-The flow in one line: you write a manifest plus callback workflows, run `cascade generate-workflow`, and commit the generated orchestration workflows into your repository. From then on, GitHub Actions runs them: cascade orchestrates on merge, promotes between environments, and releases at the terminal environment.
-
-## Prerequisites
-
-- **GitHub Actions enabled** on the repository, with trunk-based development (a single primary branch).
-- **Conventional Commits are required.** cascade derives the semver bump, the changelog, and breaking-change detection entirely from Conventional Commit messages. This is not optional. Commits that do not follow the convention are not processed correctly and version derivation can fail. See [Versioning and schema compatibility](/cascade/versioning/).
-- **GitHub setup**: environments for each deploy stage, branch and tag protection, the secrets your callbacks consume, and scoped tokens. The [Security and Hardening](/cascade/security/hardening/) checklist is the authoritative list; wire it up before your first production promotion.
-- The `cascade` CLI for local generation (Go 1.26.4+ to `go install`; in Actions, the setup action installs it for you). See [Getting Started](/cascade/getting-started/).
-
-## Build a pipeline from scratch
-
-**Fast path:** `cascade init` does the first three steps below for you. It scaffolds the manifest and the callback stubs, verifies them through the real generator, and writes them into your repository:
-
-```bash
-cascade init --topology two-env # dev, prod
-cascade init --envs staging,production # your own ordered names
-```
-
-Pick a preset with `--topology` (`no-env`, `two-env`, `three-env`, `four-env`) or supply your own ordered list with `--envs`. Then jump to step 4 to generate and commit. The walkthrough below explains each piece `init` produces, so you understand what you are filling in. See the [CLI Reference](/cascade/cli-reference/#init) for every flag.
-
-### 1. Choose your environments
-
-Environments are **positional**, not named by meaning. cascade attaches no semantics to a name like `prod`; it reads the list by position:
-
-- The **last** environment is the release stage (typically prod).
-- The **second-to-last** is the prerelease environment.
-- The crossing into the last environment is the publish boundary, where the breaking-change gate runs and the publish callback fires.
-
-So `environments: [dev, test, prod]` means dev is first, test is the prerelease stage, and prod is the release stage. The names are yours to choose; only the order carries meaning. See the [Manifest Reference](/cascade/configuration/) for the full structural rules, including zero-environment (release-only) mode.
-
-### 2. Write a minimal manifest
-
-A three-environment manifest with one build, one deploy, optional validation, and an optional publish callback:
-
-```yaml
-ci:
- config:
- trunk_branch: master
- environments: [dev, test, prod]
- cli_version: v2.0.4
-
- validate:
- workflow: .github/workflows/validate.yaml
-
- builds:
- - name: app
- workflow: .github/workflows/build-app.yaml
- triggers: ["src/**", "Dockerfile", "go.mod"]
-
- deploys:
- - name: services
- workflow: .github/workflows/deploy-services.yaml
- depends_on: [app] # receives the build's outputs as inputs
-
- publish:
- workflow: .github/workflows/publish.yaml
-
- state:
- dev: {}
- test: {}
- prod: {}
-```
-
-cascade manages `state:` and `latest_release:`; the empty skeleton is enough. See the [Manifest Reference](/cascade/configuration/) for every field.
-
-For autocomplete and inline validation while you edit the manifest, register the JSON Schema with your editor. See [Editor support](/cascade/configuration/#editor-support).
-
-### 3. Provide the callback workflows
-
-Each callback is a reusable workflow with an `on: workflow_call` trigger. cascade passes a fixed set of inputs and reads back any `outputs:` you declare. The exact, full YAML for each lives in the [Callback Contract](/cascade/callback-contract/); the contract below is the summary.
-
-| Callback | cascade passes (inputs) | You return (outputs) |
-|----------|-------------------------|----------------------|
-| **Validate** | `environment`, `sha`, `dry_run` | none required |
-| **Build** | `environment`, `sha`, `dry_run`, plus custom inputs | `artifact_id` (recommended), plus custom |
-| **Deploy** | `environment`, `sha`, `dry_run`, plus the build's declared outputs (for example `image_tag`, `artifact_id`) | custom (optional) |
-| **Changelog** (custom) | `changelog_base_sha`, `head_sha`, `repo` | `changelog` |
-| **Publish** | `build_name`, `old_version`, `new_version`, `sha`, `artifact_id` | none required |
-
-Two mechanics to internalize:
-
-- **Output chaining.** cascade parses your build workflow for declared `on.workflow_call.outputs`. When a deploy declares `depends_on: [app]`, every output the `app` build declares (say `image_tag`) is forwarded to the deploy as an input of the same name automatically. Declare outputs explicitly or they will not chain.
-- **Dry run.** Every callback receives `dry_run` and should guard mutating steps with `if: ${{ !inputs.dry_run }}`.
-
-A minimal deploy callback skeleton, receiving `image_tag` from its build dependency:
-
-```yaml
-name: Deploy Services
-on:
- workflow_call:
- inputs:
- environment: { type: string, required: true }
- sha: { type: string, required: true }
- dry_run: { type: boolean, required: false, default: false }
- image_tag: { type: string, required: true } # from the app build's outputs
-jobs:
- deploy:
- runs-on: ubuntu-latest
- environment: ${{ inputs.environment }} # protection gate lives here, not on the caller
- steps:
- - uses: actions/checkout@v4
- with: { ref: ${{ inputs.sha }} }
- - if: ${{ !inputs.dry_run }}
- run: ./deploy.sh "${{ inputs.image_tag }}"
-```
-
-The `environment:` key must sit on the job **inside** your reusable workflow. GitHub Actions rejects `environment:` on a job that calls a reusable workflow with `uses:`, so the caller cascade generates cannot carry the gate; your workflow applies it. Full build, deploy, validate, and publish skeletons are in the [Callback Contract](/cascade/callback-contract/).
-
-### 4. Generate and commit
-
-```bash
-cascade generate-workflow --config .github/manifest.yaml
-```
-
-Commit the generated orchestration workflows (`orchestrate`, `promote`, release) alongside your callbacks. Review the generated YAML before adopting it, as the hardening checklist advises.
-
-### 5. Runtime flow
-
-- **Orchestrate on merge.** A merge to trunk triggers the orchestrate workflow, which runs validate and build for the first environment and records state.
-- **Promote between environments.** A `promote` dispatch advances the recorded SHA to the next environment, re-running the relevant callbacks against it. Promotion is SHA-pinned, so what you tested is what advances.
-- **Release at the terminal environment.** Crossing into the last environment publishes the final semver release (from the RC), runs the breaking-change gate, and fires the publish callback to retag artifacts.
-
-Two capabilities sit alongside the main flow. **Hotfix** lets you patch a released version through an integration branch without dragging unreleased trunk changes along. **Rollback** re-promotes a prior environment snapshot from recorded state. See [Architecture](/cascade/architecture/) for how state and promotion underpin both.
-
-## Migrate an existing pipeline to cascade
-
-Map your current pieces onto cascade's split of responsibility. The recurring question is: does cascade take this over, or does it stay yours behind a callback?
-
-| You have today | In cascade | What changes |
-|----------------|-----------|--------------|
-| A lint/test job | A **validate** callback | Move the checks into a `workflow_call` workflow; cascade calls it with `environment`, `sha`, `dry_run` before build. If validate runs in the same pass as build and deploy today, see [Splitting a monolithic pipeline](#splitting-a-monolithic-pipeline). |
-| A build/package step | A **build** callback | Move the step into a `workflow_call` workflow; declare `artifact_id` (and any tags) as outputs so they chain to deploys and to publish. |
-| A deploy script or job | A **deploy** reusable-workflow callback | Move the script into a `workflow_call` workflow; cascade calls it with `environment`, `sha`, `dry_run`, plus build outputs. If build and deploy are fused today, see [Splitting a monolithic pipeline](#splitting-a-monolithic-pipeline). |
-| A release-tagging or retag step (promoting an RC artifact to its final version) | A **publish** callback | Move it into a `workflow_call` workflow; cascade calls it once per build at the release boundary with `build_name`, `old_version`, `new_version`, `sha`, `artifact_id`. |
-| Hand-rolled env-promotion logic (scripts gating dev to staging to prod) | cascade's **promotion cascade** | cascade owns this. Delete your promotion glue; cascade orchestrates, pins SHAs, and gates the release boundary. |
-| Manual or tool-driven version bumping | **Conventional-commit-driven** version derivation | cascade owns it and it is **required**. Your bump logic goes away; commit messages drive the semver. |
-| A changelog tool (release-please, git-cliff) | A **changelog** callback, or keep the tool and disable cascade's changelog | Two valid paths (see below). |
-| A release tool (goreleaser) | An external **release** wired via `release.tag`, or disable cascade's release and keep the tool | Two valid paths (see below). |
-
-The clean line: **cascade takes over orchestration, promotion, state, versioning, and the release boundary.** It does **not** take over how you build, deploy, validate, or (optionally) cut changelogs and release artifacts. Those stay yours, expressed as callbacks.
-
-### Splitting a monolithic pipeline
-
-A common starting point is a single workflow or job that does everything in one pass: lint and test, build the artifact, deploy it, and tag the release, often rebuilding the artifact at each environment. cascade cannot orchestrate that shape, so splitting it is the primary adaptation work of adopting cascade.
-
-cascade runs your pipeline as **discrete stages**, each its own `workflow_call` reusable workflow: validate, build, deploy, and (at the release boundary) publish. It calls each stage separately with the fixed input contract. A monolith usually fuses several of these into one pass, so adoption means teasing them back apart along the seams cascade calls across:
-
-- **Validate** (the lint/test portion) becomes its own callback that cascade runs before build.
-- **Build** and **deploy** become separate callbacks. This is the load-bearing split, and it is required rather than stylistic, because of how promotion works: cascade builds the artifact **once** (on the first environment, during orchestrate) and then promotes that same artifact, pinned to a SHA, across every later environment, running **only** deploy there. It never rebuilds per stage. A build that lives inside the deploy step would rebuild at every environment and break that guarantee.
-- **Publish** (the release-tagging or retag step) becomes its own callback that cascade fires once per build at the prerelease-to-release boundary.
-
-To make the build and deploy split, the consequential one:
-
-- **Extract the build** into its own `workflow_call` workflow that emits its artifact identifier as an output (for example `artifact_id` and any `image_tag`) under `on.workflow_call.outputs`. cascade captures `artifact_id` into state and forwards declared outputs to dependent deploys.
-- **Extract the deploy** into its own `workflow_call` workflow that receives that identifier as an input of the same name. With `depends_on: []`, cascade chains the build's outputs into the deploy automatically, so the deploy applies the prebuilt artifact instead of producing one.
-- **Stop rebuilding in deploy.** Remove the build steps from the deploy path entirely. The deploy consumes the identifier it is handed; the GitHub Environment gate lives on the job inside this deploy workflow (see [Provide the callback workflows](#3-provide-the-callback-workflows)).
-
-cascade itself owns the orchestration workflows (orchestrate, promote, release, rollback, hotfix) and derives versions and changelogs for you, so none of that moves into a callback. If you also cut a changelog as part of that monolithic pass, that step is optional and stays a separate concern: keep your tool behind a changelog callback or disable cascade's changelog (see [Keep release-please (or git-cliff)](#keep-release-please-or-git-cliff)).
-
-The result is the same logic you have today, separated along the seams cascade promotes and releases across. See the [Callback Contract](/cascade/callback-contract/) for the full validate, build, deploy, and publish skeletons.
-
-## Wiring existing tooling
-
-### Keep release-please (or git-cliff)
-
-Two options, both supported by the `changelog:` section of the [Manifest Reference](/cascade/configuration/):
-
-- **Custom changelog callback.** Point `changelog.workflow` at a reusable workflow that wraps your tool. cascade passes `changelog_base_sha`, `head_sha`, and `repo`; your workflow must return a `changelog` output. cascade uses that text when it cuts the release.
-- **Disable and keep yours as-is.** Set `changelog.disabled: true` and let your existing release-please workflow run independently on its own trigger. cascade stops generating a changelog; everything else (promotion, release) still works.
-
-### Keep goreleaser
-
-cascade has **no separate "release callback"** that receives `build_name`/`old_version`/`new_version`. Releasing is either cascade's own job or your external tool. Two options via the `release:` section:
-
-- **External release tool.** Keep your goreleaser callback as a normal build/deploy callback that emits a tag output, and set `release.tag: goreleaser.tag` (the `callback.output` reference). cascade defers the tag to your tool's output.
-- **Disable and keep goreleaser standalone.** Set `release.disabled: true` to turn off cascade's release management and run goreleaser on your own trigger.
-
-Omitting the `release:` section entirely uses cascade's defaults: it creates releases with conventional-commit changelogs.
-
-### Conventional commits are required
-
-cascade's version derivation, breaking-change detection, and default changelog are **conventional-commit-only by design**. If your history contains commits that do not follow the convention, version derivation can fail and breaking changes will be missed. Adopt the convention before (or as part of) migrating. Details in [Versioning and schema compatibility](/cascade/versioning/).
-
-## Hardening checklist pointer
-
-Before a production promotion, confirm at minimum:
-
-- Branch protection plus **CODEOWNERS on `.github/workflows/**`** so generated and callback workflows require review.
-- **Environment protection rules** (required reviewers, branch/tag policy) on each deploy environment, declared inside the reusable deploy workflow.
-- **Scoped tokens** for cross-repo dispatch and release API calls; prefer a GitHub App or short-lived token over a broad PAT.
-- **Audit and integrity**: pinned actions, immutable registry tags, OIDC with a tight trust policy.
-
-The full, ordered checklist is in [Security and Hardening](/cascade/security/hardening/). Work through it there; the points above are the highlights, not the whole list.
-
-## Topologies
-
-Environment count is structural. Pick the shape that matches your project:
-
-- **No-env (release-only)**: omit `environments`. For libraries and CLIs that publish releases without deploying anywhere.
-- **2-env**: `dev` + `prod`. Smallest promotion chain with a prerelease stage.
-- **3-env**: `dev` + `staging` + `prod` (or `pre` + `staging` + `prod`). The common default.
-- **4-env**: `dev` + `staging` + `pre` + `prod`. Adds a dedicated prerelease stage before production.
-
-Worked example repositories are not published yet (examples TBD). Until then, the [Getting Started](/cascade/getting-started/) walkthrough and the [Callback Contract](/cascade/callback-contract/) skeletons are the reference implementations.
diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md
deleted file mode 100644
index c7780a5c..00000000
--- a/docs/src/content/docs/architecture.md
+++ /dev/null
@@ -1,620 +0,0 @@
----
-title: Architecture
-description: System design and internals of cascade.
----
-
-System design and internals of cascade.
-
-## Design Principles
-
-1. Build once, deploy everywhere. One artifact is promoted through every environment.
-2. Change-driven. We build and deploy only what changed.
-3. Trunk-based. A single main branch backs short-lived feature branches.
-4. Callback contract. The framework orchestrates and adopting repos own build and deploy.
-5. State tracking. The manifest records what is deployed where.
-
-## System Overview
-
-```mermaid
-flowchart TD
- subgraph cascade["cascade"]
- direction TB
- subgraph surfaces[" "]
- direction LR
- cli["CLI Tool (cascade)"]
- wf["Workflows (reusable)"]
- act["Actions (composite)"]
- end
- subgraph pkgs["Go Packages"]
- direction LR
- config["config"]
- changes["changes"]
- changelog["changelog"]
- generate["generate"]
- release["release"]
- orchestrate["orchestrate"]
- promote["promote"]
- version["version"]
- reset["reset"]
- git["git"]
- end
- surfaces --> pkgs
- end
-
- cascade --> repos["Adopting Repos (callbacks)"]
- cascade --> api["GitHub API (releases)"]
-```
-
-## Directory Structure
-
-```
-cascade/
-├── cmd/
-│ └── cascade/ # CLI entry point
-│ └── main.go
-├── internal/
-│ ├── config/ # Config parsing and validation
-│ │ ├── parse.go
-│ │ ├── types.go
-│ │ └── command.go
-│ ├── changes/ # Change detection
-│ │ ├── detect.go
-│ │ ├── glob.go
-│ │ └── command.go
-│ ├── changelog/ # Conventional commit parsing
-│ │ ├── parse.go
-│ │ ├── format.go
-│ │ └── command.go
-│ ├── generate/ # Workflow generation
-│ │ ├── generator.go
-│ │ ├── graph.go
-│ │ ├── workflow.go
-│ │ └── command.go
-│ ├── release/ # GitHub release management
-│ │ ├── manager.go
-│ │ └── command.go
-│ ├── orchestrate/ # Main CI/CD pipeline logic
-│ │ ├── setup.go
-│ │ ├── finalize.go
-│ │ └── command.go
-│ ├── promote/ # Promotion pipeline logic
-│ │ ├── preflight.go
-│ │ ├── finalize.go
-│ │ └── command.go
-│ ├── version/ # Semantic versioning
-│ │ ├── calculate.go
-│ │ └── command.go
-│ ├── reset/ # Test reset utility
-│ │ └── command.go
-│ └── git/ # Git operations
-│ └── git.go
-├── .github/
-│ ├── actions/ # Composite actions
-│ │ ├── setup-cli/
-│ │ └── manage-release/
-│ ├── workflows/ # Reusable workflows
-│ │ ├── orchestrate.yaml
-│ │ ├── promote.yaml
-│ │ ├── hotfix.yaml
-│ │ └── build-cli.yaml
-│ └── cicd.yaml # Self-hosting config
-├── e2e/ # End-to-end tests
-│ ├── scenarios/
-│ └── harness/
-└── docs/ # Documentation
-```
-
-## Go Packages
-
-### internal/config
-
-Parses and validates `.github/cicd.yaml`.
-
-```go
-// CICDFile combines config and state in a single structure
-type CICDFile struct {
- Config TrunkConfig `yaml:"config"`
- State map[string]*EnvState `yaml:"state"`
-}
-
-type TrunkConfig struct {
- Project string `yaml:"project"`
- TrunkBranch string `yaml:"trunk_branch"`
- Environments []string `yaml:"environments"`
- CLIVersion string `yaml:"cli_version,omitempty"`
- Git *GitConfig `yaml:"git,omitempty"`
- Validate *ValidateConfig `yaml:"validate,omitempty"`
- Builds []BuildConfig `yaml:"builds"`
- Deploys []DeployConfig `yaml:"deploys"`
- Release *ReleaseConfig `yaml:"release,omitempty"`
- Changelog *ChangelogConfig `yaml:"changelog,omitempty"`
-}
-
-type BuildConfig struct {
- Name string `yaml:"name"`
- Workflow string `yaml:"workflow"`
- Triggers []string `yaml:"triggers"`
- DependsOn []string `yaml:"depends_on"`
- StateTags []string `yaml:"state_tags"`
- Inputs map[string]any `yaml:"inputs"`
- EnvInputs map[string]map[string]any `yaml:"env_inputs"`
- RunPolicy string `yaml:"run_policy"`
- OnFailure string `yaml:"on_failure"`
- Retries int `yaml:"retries"`
-}
-
-type EnvState struct {
- SHA string `yaml:"sha,omitempty"`
- Version string `yaml:"version,omitempty"`
- CommittedAt string `yaml:"committed_at,omitempty"`
- CommittedBy string `yaml:"committed_by,omitempty"`
- Builds map[string]*BuildState `yaml:"builds,omitempty"`
- Deploys map[string]*DeployState `yaml:"deploys,omitempty"`
-}
-```
-
-Key methods:
-- `Parse(path) -> *CICDFile, error`
-- `Validate() -> []error`
-- `GetTriggersForDeploy(name) -> []string`
-- `GetNextEnvironment(env) -> string`
-- `GetAllDirectPromotionOptions() -> []string`
-- `IsFirstEnvironment(env) -> bool`
-- `IsLastEnvironment(env) -> bool`
-
-### internal/changes
-
-Detects which builds/deploys are triggered by file changes.
-
-```go
-type DetectResult struct {
- TriggeredBuilds []string `json:"triggered_builds"`
- TriggeredDeploys []string `json:"triggered_deploys"`
- HasChanges bool `json:"has_changes"`
- ChangedFiles []string `json:"changed_files"`
-}
-
-func Detect(cfg *config.TrunkConfig, baseSHA, headSHA string) (*DetectResult, error)
-```
-
-Glob matching supports:
-- `*` - any characters except `/`
-- `**` - any path segments (recursive)
-- `?` - single character
-
-### internal/changelog
-
-Parses conventional commits and generates markdown.
-
-```go
-type Commit struct {
- Hash string
- Type string
- Scope string
- Description string
- Body string
- IsBreaking bool
-}
-
-func ParseCommit(subject, body string) *Commit
-func CategorizeCommits(commits []*Commit) map[string][]*Commit
-func FormatMarkdown(categories map[string][]*Commit, repo string) string
-```
-
-Categories:
-- Breaking Changes (any `!` or `BREAKING CHANGE:`)
-- Features (`feat`)
-- Bug Fixes (`fix`)
-- Other (non-routine types)
-
-### internal/generate
-
-Generates orchestration workflows from config.
-
-```go
-type Generator struct {
- Config *config.TrunkConfig
- OutputDir string
-}
-
-func (g *Generator) GenerateOrchestrate() (string, error)
-func (g *Generator) GeneratePromote() (string, error)
-```
-
-Features:
-- Dependency graph with topological sort
-- Auto-discovers outputs from workflow files
-- Generates conditional jobs per callback
-- Handles `depends_on` ordering
-
-### internal/release
-
-Manages GitHub releases via API.
-
-```go
-type Manager struct {
- Client *http.Client
- Repo string
-}
-
-func (m *Manager) Create(tag, name, body string, draft, prerelease bool) (*Release, error)
-func (m *Manager) Update(id int, opts UpdateOptions) error
-func (m *Manager) Lock(id int) error // Mark as pre-release
-func (m *Manager) Publish(id int) error // Remove pre-release flag
-func (m *Manager) Delete(id int) error
-```
-
-### internal/orchestrate
-
-Core CI/CD pipeline logic for merges to trunk.
-
-```go
-type SetupResult struct {
- TriggeredBuilds []string `json:"triggered_builds"`
- TriggeredDeploys []string `json:"triggered_deploys"`
- Version string `json:"version"`
- ExecutionPlan Plan `json:"execution_plan"`
-}
-
-func Setup(cfg *config.CICDFile, env, baseSHA, headSHA string) (*SetupResult, error)
-func Finalize(cfg *config.CICDFile, env, sha, repo string, dryRun bool) error
-```
-
-Responsibilities:
-- Parse config and detect changes
-- Calculate semantic version
-- Build execution plan with dependency ordering
-- Update state after deployment
-- Create/update draft release with changelog
-
-### internal/promote
-
-Promotion pipeline logic for environment-to-environment promotions.
-
-```go
-type PreflightResult struct {
- SourceEnv string `json:"source_env"`
- TargetEnv string `json:"target_env"`
- SourceSHA string `json:"source_sha"`
- TriggeredDeploys []string `json:"triggered_deploys"`
- SkippedDeploys []string `json:"skipped_deploys"`
- Version string `json:"version"`
-}
-
-func Preflight(cfg *config.CICDFile, promotion, repo string) (*PreflightResult, error)
-func Finalize(cfg *config.CICDFile, promotion, sha, repo string, dryRun bool) error
-```
-
-Key features:
-- Per-deployable change detection
-- Determines which deploys need updates based on trigger path changes
-- Handles release state transitions (draft -> pre-release -> published)
-
-### internal/version
-
-Semantic versioning calculation based on conventional commits.
-
-```go
-type VersionResult struct {
- Version string `json:"version"`
- BumpType string `json:"bump_type"`
- HasBreaking bool `json:"has_breaking"`
- HasFeatures bool `json:"has_features"`
-}
-
-func Calculate(cfg *config.CICDFile, env, baseSHA, headSHA string) (*VersionResult, error)
-```
-
-Algorithm:
-- Breaking changes -> major bump
-- Features -> minor bump
-- Fixes -> patch bump
-- Pre-release environments get RC suffix
-
-### internal/reset
-
-Testing utility for wiping releases and state.
-
-```go
-func Reset(cfg *config.CICDFile, repo string, dryRun, push bool) error
-```
-
-Actions:
-- Delete all GitHub releases
-- Delete all git tags
-- Reset state in config file
-
-### internal/git
-
-Wrapper for git operations.
-
-```go
-func GetChangedFiles(baseSHA, headSHA string) ([]string, error)
-func GetCommits(baseSHA, headSHA string, excludePaths []string) ([]*Commit, error)
-func ConfigureIdentity(mode, userName, userEmail string) error
-func SetupGPGSigning(keyID, privateKey string) error
-```
-
-## Workflow Architecture
-
-### Orchestrate Flow
-
-```
-on-merge.yaml (adopting repo)
- │
- ▼
-orchestrate.yaml (framework)
- │
- ├─► setup job
- │ ├─ Parse config
- │ ├─ Detect changes
- │ └─ Build execution plan
- │
- ├─► validate job (optional)
- │
- ├─► build jobs (matrix)
- │ └─ Per triggered build
- │
- ├─► deploy jobs (matrix)
- │ └─ Per triggered deploy, respecting depends_on
- │
- └─► finalize job
- ├─ Update manifest
- ├─ Generate changelog
- └─ Create/update release
-```
-
-### Promote Flow
-
-```
-promote.yaml (adopting repo)
- │
- ▼
-promote.yaml (framework)
- │
- ├─► validate job
- │ ├─ Check source SHA
- │ ├─ Determine target env
- │ └─ Compute deploys to run
- │
- ├─► deploy jobs (matrix)
- │ └─ Per-deployable with change detection
- │
- └─► finalize job
- ├─ Update manifest (per-deploy SHAs)
- ├─ Generate changelog
- └─ Create/publish release
-```
-
-## Dependency Graph
-
-Callbacks are ordered using topological sort:
-
-```
-builds: [app, wiremock]
-deploys:
- - cdk (depends_on: [])
- - services (depends_on: [cdk, app])
- - monitoring (depends_on: [services])
-
-Execution waves:
- Wave 1: app, wiremock, cdk (no dependencies)
- Wave 2: services (depends on wave 1)
- Wave 3: monitoring (depends on wave 2)
-```
-
-## Manifest State Machine
-
-```mermaid
-stateDiagram-v2
- [*] --> Empty
- Empty --> Dev: merge to trunk
- Dev --> Test: manual promote
- Test --> Prod: manual promote
- note right of Test
- pre-release created
- end note
- note right of Prod
- release published
- end note
-```
-
-Each environment tracks:
-- `sha` - deployed commit
-- `image_tag` - docker tag
-- `deployed_at` - timestamp
-- `deployed_by` - actor
-- `version` - semver (prod only)
-- `deploys` - per-deployable SHAs
-
-## Change Detection Algorithm
-
-```
-1. Get changed files: git diff --name-only base..head
-
-2. For each build:
- if any(changed_file matches any trigger pattern):
- mark build as triggered
-
-3. For each deploy:
- if deploy has depends_on referencing a build:
- if that build is triggered:
- mark deploy as triggered
- else if deploy has triggers:
- if any(changed_file matches any trigger pattern):
- mark deploy as triggered
- else:
- mark deploy as triggered (unconstrained)
-
-4. Return triggered builds/deploys
-```
-
-## Per-Deployable Tracking
-
-For promotions, the framework tracks each deployable independently:
-
-```
-Source (dev):
- sha: abc123
- deploys:
- cdk: { sha: abc123 }
- services: { sha: abc123 }
-
-Target (test):
- sha: def456 # older
- deploys:
- cdk: { sha: abc123 } # already at latest
- services: { sha: def456 } # needs update
-
-Promotion decision:
- - cdk: skip (no changes in cdk/** between abc123 and abc123)
- - services: run (changes detected)
-```
-
-## Multi-Repo Orchestration
-
-For deployments spanning multiple repositories (e.g., backend + CDK + K8s), the framework supports coordinated promotions:
-
-### Primary/Satellite Model
-
-```mermaid
-flowchart BT
- satA["Satellite A (CDK Infra)"] -- "notify after dev deploy" --> primary
- satB["Satellite B (K8s Manifests)"] -- "notify after dev deploy" --> primary
- satC["Satellite C (Terraform)"] -- "notify after dev deploy" --> primary
-
- primary["Primary Repo (Backend) Owns environment state machine Coordinates all promotions Tracks external deploy state"]
-```
-
-### Communication Flow
-
-1. **Satellite deploys to dev**: Satellite runs its own orchestrate workflow
-2. **Satellite notifies primary**: Dispatches to primary's `external-update.yaml`
-3. **Primary updates state**: Records external deploy SHA/version in manifest
-4. **Promotion includes all**: When promoting, primary triggers all deploys (local + external)
-
-The topology above shows which repos talk to which. The flow below shows what actually moves between them: each external repo dispatches the primary's `external-update.yaml` with a payload, the primary serializes those writes into the one shared manifest, then cascades every source through its environments.
-
-```mermaid
-flowchart TD
- subgraph EXT["External artifact repos"]
- direction LR
- A["artifact-a builds its own artifact"]
- B["artifact-b builds its own artifact"]
- end
-
- A -- "workflow_dispatch source_repo · deploy_name · environment sha · version · artifacts" --> EU
- B -- "workflow_dispatch source_repo · deploy_name · environment sha · version · artifacts" --> EU
-
- subgraph PRIMARY["Primary repo"]
- direction TB
- EU["external-update.yaml cascade external update"]
- EU -- "writes {sha, version}" --> ST[".github/manifest.yaml state.<env>.external.<name> concurrent updates serialize"]
- ST --> PR
- subgraph PR["Promote (cascade through environments)"]
- direction LR
- dev["dev"] --> test["test"] --> staging["staging"] --> prod["prod"]
- end
- end
-
- CB["Primary build / deploy callback"] -. "sync uses: org/artifact-repo/.github/workflows/<name>.yaml@ref" .-> SYNC["External workflow invoked inline"]
-```
-
-### State Tracking
-
-Primary manifest tracks external deploys alongside local deploys:
-
-```yaml
-state:
- dev:
- sha: abc123
- deploys:
- app: { sha: abc123 } # Local deploy
- external:
- cdk: { repo: org/cdk-infra, sha: cdk123 } # External deploy
- k8s: { repo: org/k8s-manifests, sha: k8s456 } # External deploy
-```
-
-### Generated Workflows
-
-For primary repos with external config:
-- `external-update.yaml`: Accepts satellite notifications
-- `promote.yaml`: Includes jobs for both local and external deploys
-
-For satellite repos with notify config:
-- `orchestrate.yaml`: Finalize step dispatches to primary
-
-## Security Model
-
-- Framework workflows run in adopting repo context
-- Secrets passed via `secrets: inherit`
-- Environment protection via GitHub environments
-- No secrets stored in framework repo
-- Cross-repo dispatch requires appropriate tokens (e.g., `PRIMARY_REPO_TOKEN`)
-
-## Extension Points
-
-1. **Custom Changelog**: override with `changelog.workflow`
-2. **Custom Release**: override with `release.tag` for external tools
-3. **Custom Inputs**: pass arbitrary inputs via `inputs`/`env_inputs`
-4. **Output Chaining**: outputs auto-discovered and passed to dependents
-5. **GitHub Environments**: `environment_config` per-env settings emitted by `cascade environments`; see [GitHub Deployments API and Environments REST](#github-deployments-api-and-environments-rest) below
-
-## GitHub Deployments API and Environments REST
-
-### What cascade does today
-
-The generator emits an `environment: ` key on each deploy job whenever the manifest includes an `environments` list. That single key is enough for GitHub Actions to attach deployment records, honour required-reviewer gates, apply wait timers, and scope environment secrets. You configure all of that inside GitHub, not in the manifest.
-
-When you opt in with `deployments.enabled: true`, the finalize job also reports deployment status through the Deployments API: it calls `POST /repos/{owner}/{repo}/deployments` to create a Deployment for the runtime-selected environment, `POST /repos/{owner}/{repo}/deployments/{id}/statuses` to mark it `in_progress`, then a terminal `success` or `failure` status once the deploy callbacks finish. See [Native deployments](/cascade/configuration/#native-deployments-opt-in) for the toggle and the per-environment `environment_url`.
-
-### What is deferred
-
-One capability remains out of scope for v1:
-
-- Environments REST configuration sync. cascade does not CALL the Environments REST API: it never reads or writes environment protection rules (required reviewers, wait timers, branch policies) over the wire. The manifest can now EXPRESS that configuration, and `cascade environments` emits it as an operator-appliable file (apply with `gh api` or Terraform), but applying it stays an operator step. cascade emits; the operator applies.
-
-### Why deferred
-
-Keeping cascade out of the Environments REST API in v1 bounds the surface area and avoids coupling the tool to GitHub API semantics that are still evolving. Adding programmatic control before an adopter needs it would buy complexity and nothing else. If that API changes shape, cascade would have to track the change even though nothing in v1 depends on it.
-
-### How the design reserves the extension points
-
-The schema already carries the hooks needed to add both capabilities later without a breaking change:
-
-**`environment_config` shape.** The manifest schema carries an `environment_config` block at the `config:` level, keyed by environment name:
-
-```yaml
-config:
- environments: [dev, test, prod] # ordered list (source of truth), unchanged
- environment_config: # optional; omitting it is valid
- prod:
- gha_environment: production # maps to the GHA environment name
- required_reviewers: [team/ops] # user/team slugs
- wait_timer: 10 # minutes (0..43200)
- branch_policy: protected # protected | custom | all
- branch_patterns: [release/*] # custom policy only
- tag_patterns: [v*] # custom policy only
- secrets: [MY_SECRET] # expected env-scoped secret names
- variables: [REGION] # expected env-scoped variable names
- environment_url: https://... # reported on the Deployment status (native deployments)
-```
-
-The protection fields (`required_reviewers`, `wait_timer`, `branch_policy`, `branch_patterns`, `tag_patterns`) and the expected `secrets` and `variables` names are real, additive fields, not reserved placeholders. `cascade environments` reads them and emits an operator-appliable file (see [environments](/cascade/cli-reference/#environments)). cascade still never calls the REST API: it forms the PUT body it can fully express from the manifest and surfaces the rest, including the reviewer slugs and the secret and variable names, under `operator_todo` for the operator to apply.
-
-The `environments` list stays a plain ordered `[]string`; the separate `environment_config` map carries per-env settings. Adding fields under `environment_config.` is additive and never touches the ordering semantics of `environments`. A manifest that omits `environment_config` entirely is valid and equivalent to today's behaviour.
-
-**Single finalize seam.** The `orchestrate.Finalize` and `promote.Finalize` functions are the only places that write state after a deployment completes. The Deployments API status reporting attaches at exactly those two points, not scattered across the generator.
-
-**Generator delegates environment semantics to GitHub.** Because the generator emits `environment:` and nothing more, it does not embed logic about what that environment means. Programmatic status reporting slots in at finalize time; Environments REST configuration sync is a separate operational concern that never needs to touch the generator.
-
-### Forward-compatibility guarantee
-
-Environments REST configuration sync, when it arrives, will follow the same additive-only policy described in [Versioning & Schema](/cascade/versioning/): new optional fields under `environment_config.`, new optional top-level blocks if needed, and no removal or re-typing of existing fields. It will not require a `schema_version` bump. The Deployments API status reporting already shipped under that same policy: `deployments` and `environment_url` are additive opt-in fields that did not bump the schema version. Manifests that do not opt in to the new fields continue to work exactly as they do today.
-
-## Testing Strategy
-
-- Unit tests for core logic (glob matching, commit parsing)
-- Integration tests for workflow generation
-- Template tests for YAML validity
-- PoC validation with test repository
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
deleted file mode 100644
index f0bb13db..00000000
--- a/docs/src/content/docs/configuration.md
+++ /dev/null
@@ -1,941 +0,0 @@
----
-title: Configuration Reference
-description: Complete reference for every field in the cascade manifest file, including config, state, and policy sections.
----
-
-Complete reference for the manifest file (default `.github/manifest.yaml`).
-
-## File Structure
-
-The manifest holds both pipeline configuration and deployment state under a top-level `ci:` key:
-
-```yaml
-ci:
- config: # Pipeline definition (you write this)
- trunk_branch: master
- environments: [dev, test, prod]
- # builds, deploys, etc.
-
- state: # Deployment tracking (managed by the framework, do not edit)
- dev:
- sha: "abc123"
- version: "v1.2.0-rc.3"
- committed_at: "2026-01-15T10:30:00Z"
-
- latest_release: # Most recent published release (managed)
- version: "v1.1.0"
- sha: "abc000"
-```
-
-The wrapper key (`ci:` by default) is configurable via `config.manifest_key`. The file path is configurable via `config.manifest_file`.
-
-## Editor support
-
-cascade ships a hand-authored JSON Schema for the manifest. Registering it with your editor gives you autocomplete, type checking, enum hints, and hover documentation while you author `.github/manifest.yaml`. The schema covers structure, types, and enums; `cascade parse-config` remains the authority for semantic and cross-field rules.
-
-The schema is published at:
-
-```
-https://stablekernel.github.io/cascade/manifest.schema.json
-```
-
-You can also print the embedded copy with `cascade schema` (write it to a file with `cascade schema --output manifest.schema.json`).
-
-### YAML language server directive
-
-Add this comment to the top of `.github/manifest.yaml`. The YAML language server (used by VS Code, Neovim, and others) reads it automatically:
-
-```yaml
-# yaml-language-server: $schema=https://stablekernel.github.io/cascade/manifest.schema.json
-ci:
- config:
- trunk_branch: main
-```
-
-### VS Code settings
-
-Alternatively, map the schema to your manifest path in `settings.json`:
-
-```json
-{
- "yaml.schemas": {
- "https://stablekernel.github.io/cascade/manifest.schema.json": ".github/manifest.yaml"
- }
-}
-```
-
-If your manifest uses a different path or wrapper key, point the mapping at your file. Either registration path works; the directive travels with the file, while the settings mapping is per-workspace.
-
-## Config Section
-
-### Top-Level Fields
-
-```yaml
-ci:
- config:
- trunk_branch: master
- environments: [dev, test, prod]
- cli_version: v2.0.4
-```
-
-| Field | Type | Required | Default | Description |
-|-------|------|----------|---------|-------------|
-| `trunk_branch` | string | Yes | - | Main branch (e.g., `master`, `main`) |
-| `environments` | list | No | - | Promotion chain. Omit for no-env library/CLI projects. |
-| `cli_version` | string | No | latest | CLI version: `latest`, `beta`, or specific version (e.g., `v2.0.4`) |
-| `cli_version_sha` | string | No | - | 40-hex commit SHA that `cli_version` resolves to. With `pin_mode: sha`, the generated setup-cli ref is pinned to this commit. See [cli_version_sha](#cli_version_sha). |
-| `triggers` | list | No | - | Global path patterns that activate orchestration |
-| `release_trigger` | string | No | `push` | How the orchestrate workflow fires. `push` keeps the push-on-trunk plus `workflow_dispatch` triggers; `dispatch` drops the `push:` trigger so releases run only on manual `workflow_dispatch`. See [Release trigger](#release-trigger). |
-| `pin_mode` | string | No | `tag` | Third-party action pin policy. `tag` emits `@`; `sha` emits `@` with the version as a trailing comment. See [Action pinning](#action-pinning). |
-| `action_pins` | map | No | - | Per-action ref overrides keyed by action path (e.g. `actions/checkout`), applied regardless of `pin_mode`. This is also the storage target `cascade reconcile` writes an adopted external pin bump into (see [reconcile](/cli-reference/#reconcile)). See [Action pinning](#action-pinning). |
-| `tag_prefix` | string | No | `v` | Version tag prefix |
-| `release_token` | string | No | `state_token` if set, else `${{ secrets.GITHUB_TOKEN }}` | Token expression for release API calls and the rc tag; inherits `state_token` when unset so the rc-to-release chain has a trigger-capable token |
-| `state_token` | string | No | `${{ secrets.GITHUB_TOKEN }}` | Token expression for writing manifest state to the trunk branch |
-| `release_token_app` | object | No | - | GitHub App identity that mints a release token at run time; see [Token authentication](#token-authentication) |
-| `state_token_app` | object | No | - | GitHub App identity that mints a state-write token at run time; see [Token authentication](#token-authentication) |
-| `manifest_file` | string | No | `.github/manifest.yaml` | Path to manifest file |
-| `manifest_key` | string | No | `ci` | Top-level key inside the manifest file |
-| `action_folder` | string | No | `manage-release` | Folder name for the manage-release action |
-
-:::note[Environment names are yours; roles are positional]
-The `environments` list is fully configurable. cascade attaches no meaning to specific labels: `dev`, `test`, `uat`, `staging`, and `prod` are illustrative examples used throughout these docs, not reserved names. Roles are decided by position in the list, not by name. The last environment is the release stage (prod), the second-to-last is the prerelease environment, and the publish boundary is the final crossing into the last environment. The count is structural too: zero environments is release-only, one environment generates a single-environment Release workflow, and two or more enable the full promote cascade.
-
-**Naming.** Environment, build, and deploy names become GitHub Actions job IDs and output-variable keys, so keep them identifier-safe: use letters, digits, and underscores (hyphens are read as subtraction in GitHub Actions expressions). The reserved generator-owned names `environment` and `dry_run` cannot be used as `dispatch_inputs`. A `dispatch_inputs` name is emitted as a `workflow_dispatch` input key and referenced as `${{ inputs. }}`, so it is held to the same identifier-safe charset (letters, digits, hyphens, underscores); a choice input's `options` are emitted verbatim and must contain only letters, digits, dots, hyphens, and underscores (so version-like values such as `v1.2.3` are allowed, but spaces, colons, and `${{ }}` fragments are rejected). Any `gha_environment` value maps to a real GitHub Environment, so GitHub's own naming rules apply there.
-:::
-
-### cli_version
-
-Controls which CLI version the generated workflows install via setup-cli:
-
-| Value | Behavior |
-|-------|----------|
-| `latest` | Most recent stable release (default) |
-| `beta` | Latest build from the `master` branch |
-| `vX.Y.Z` | Specific version (e.g., `v2.0.4`) |
-
-Pin to a specific version for reproducibility. Use `beta` for early access.
-
-### cli_version_sha
-
-When `pin_mode: sha` is set, pair `cli_version` with `cli_version_sha`, the 40-character lowercase-hex commit SHA that the `cli_version` tag resolves to. The generated setup-cli ref is then pinned to that immutable commit, with `cli_version` carried as a trailing comment:
-
-```yaml
-uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.1.0
-```
-
-The `with: version:` input the action reads to select the release asset stays the human-readable tag, so only the action source is pinned to a commit.
-
-This closes the supply-chain gap where the cascade self-action was referenced by a mutable tag while third-party actions were already SHA-pinned. The field is optional and only takes effect under `pin_mode: sha`; leave it unset (or use the default `pin_mode: tag`) to keep the tag-based ref. Set `cli_version_sha` alongside `cli_version` whenever you bump the pinned version. Because cascade release tags are annotated, resolve the underlying commit (not the tag object) with `git ls-remote https://github.com/stablekernel/cascade 'refs/tags/^{}'`.
-
-### Release trigger
-
-`release_trigger` selects how the generated orchestrate workflow fires. It is opt-in; repos that leave it unset keep the push triggers.
-
-| Value | Behavior |
-|-------|----------|
-| `push` | Default. Orchestrate fires on trunk pushes (filtered by `triggers:`) plus `workflow_dispatch`. |
-| `dispatch` | Drops the `push:` trigger so orchestrate runs only on `workflow_dispatch`, letting a maintainer-owned gate decide when a release candidate is cut. |
-
-```yaml
-ci:
- config:
- release_trigger: dispatch
-```
-
-### Action pinning
-
-Generated workflows are build output. cascade owns the third-party action pins inside them (for example `actions/checkout` and `actions/github-script`) and reconciles that ownership back to a single source of truth: the manifest. The supported way to change a pinned action is `pin_mode` and `action_pins`, described below, not a hand-edit of the generated YAML. A hand-edited pin is reported as drift by the next `cascade verify` and overwritten by the next regenerate.
-
-A future cascade version may write a pointer comment into the generated workflow header naming the manifest that owns its pins, so ownership is visible from the file itself without consulting these docs. That pointer is not emitted today; nothing in the current output implies it.
-
-cascade also ships an opt-in reconcile companion that watches for an external action-pin change (for example a Dependabot bump landing in a generated workflow) and adopts it into `action_pins` automatically, then regenerates so every workflow agrees again. Set `reconcile.enabled: true` to emit it; see [Reconcile companion](#reconcile-companion-opt-in) below for the generated shape and [reconcile](/cli-reference/#reconcile) for the command it runs. The sections below on Dependabot, token permissions, and automerge describe the ownership model it is built on and the fallback posture for repositories that do not opt in.
-
-Two fields control the pinning policy today.
-
-`pin_mode` sets the reference style for every third-party action cascade emits:
-
-| Value | Behavior |
-|-------|----------|
-| `tag` | Default. Emits `@` (for example `actions/checkout@v4`). Never `@latest`. |
-| `sha` | Emits `@ # `, pinning each action to an immutable commit with the human-readable version as a trailing comment. Under `sha`, pair `cli_version` with [`cli_version_sha`](#cli_version_sha) so the cascade self-action ref is pinned too. |
-
-The default `sha` values come from a single committed pin table (`internal/generate/action_pins.yaml`); no per-repo configuration is needed to adopt SHA pinning beyond setting `pin_mode: sha`.
-
-`action_pins` overrides the built-in ref for individual actions, keyed by action path. The map value is the bare ref emitted after `@` for that same action path (a tag or a commit SHA); it cannot repoint an action to a different owner or repository. An override is applied regardless of `pin_mode`, so use it to hold an action at a known-good commit or tag:
-
-```yaml
-ci:
- config:
- pin_mode: sha
- action_pins:
- actions/checkout: 0123456789abcdef0123456789abcdef01234567
-```
-
-That emits `uses: actions/checkout@0123456789abcdef0123456789abcdef01234567`. An action that is neither in the built-in table nor overridden is emitted unchanged.
-
-#### `action_pins` is also the reconcile write target
-
-You do not have to hand-author every `action_pins` entry yourself. The [`cascade reconcile`](/cli-reference/#reconcile) command writes here too: when it adopts an external governed-pin change (for example a Dependabot bump landing in a generated workflow), it sets that action's `action_pins` entry to the incoming ref verbatim, keyed by action path, exactly as if you had written the override by hand. Under `pin_mode: tag` the adopted value is a bare tag (for example `v6`); under `pin_mode: sha` it is the commit sha with its trailing `# ` comment (for example `abc123def4567890abc123def4567890abc12345 # v6.0.1`). That whole string, comment included, is stored as a single YAML-quoted scalar, not a bare value followed by a real YAML comment, so it survives being re-parsed on the next reconcile or regenerate; the generator still emits it correctly as `actions/checkout@abc123def4567890abc123def4567890abc12345 # v6.0.1` in the generated workflow, identical to a hand-written sha override.
-
-#### Overriding a pin switches its update channel
-
-Setting `action_pins` for an action switches that action's update channel. Before the override, the action tracks cascade's own curated pin table (`internal/generate/action_pins.yaml`), which cascade updates as it ships new releases. Once you set an override, that action's future updates come from wherever you or your tooling point the override, not from cascade's table anymore. The override is the only state cascade keeps for that action: there is no separate record of when or why it was set, and no path back to the curated default other than removing the override yourself.
-
-One consequence of that: because the override is the only state, an adopted pin can trail cascade's own curated default over time, for example when cascade's table later moves the same action to a newer commit. This is a known, documented edge today, not something cascade reconciles automatically.
-
-#### Dependabot fallback (for repositories that do not enable reconcile)
-
-Dependabot can propose bumps directly against the actions pinned in your generated workflow files, since it reads `uses:` lines wherever they appear. For a repository that does not enable the reconcile companion, the practical fallback is excluding the generated workflow paths from Dependabot's GitHub Actions directory scan, so a bump lands in `action_pins` where cascade tracks it instead of a hand-edit that the next regenerate reports as drift. Treat this as a fallback, not the recommended posture: prefer enabling `reconcile` so the companion adopts the bump into the manifest rather than steering Dependabot away from the generated paths.
-
-#### Token permissions for pin ownership
-
-The token that writes manifest state (`state_token`, or its `_app` variant; see [Token authentication](#token-authentication)) needs headroom for pin ownership. It already needs `Contents: write` to push manifest state. The reconcile companion adds `Workflows: write` when a regenerate must also push updated workflow files, because that requires the workflow scope; without it, the push fails. The fuller set a token exercising cascade's pin ownership, hotfix, drift-check, and deployment features needs is `Metadata: read`, `Contents: read and write`, `Workflows: write`, `Actions: read and write`, `Pull requests: read and write`, `Issues: read and write`, and `Deployments: read and write`. Provisioning this set once, through a GitHub App installation token or a fine-grained PAT, avoids re-scoping every time a new feature lands. A broad classic PAT can express the same permissions but without per-repo or per-scope precision, so prefer the App or fine-grained PAT path.
-
-#### Automerge caveat
-
-Enabling the reconcile companion changes what a red drift check means. Without it, a hand-edited pin or an external bump landing in a generated file makes `cascade verify` fail and stays red until someone intervenes. With the companion enabled, that same bump is instead adopted into `action_pins` and the workflow regenerated automatically, turning what would have been a red check green. If your repository automerges once checks pass, a pin bump can land and merge unattended. If you rely on automerge, prefer the companion's followup commit-routing mode (`reconcile.commit: followup`): it opens the adoption as its own pull request rather than pushing onto the triggering one, so a human still reviews the pin change before it merges.
-
-### Token authentication
-
-Two seams call GitHub on cascade's behalf: `release_token` for release API calls and `state_token` for writing manifest state back to the trunk branch. Both default to `${{ secrets.GITHUB_TOKEN }}`, which is enough for a single-repo project whose trunk is unprotected. When the default token cannot do the job, supply your own token through one of two paths: a static secret (PAT) or a GitHub App.
-
-#### Static secret (PAT)
-
-Set `release_token` or `state_token` to a custom secret expression when the default `GITHUB_TOKEN` falls short:
-
-- **Pulling a private-source CLI.** Installing the cascade CLI from a private repository or registry needs a token with read access to that source.
-- **Cross-repo dispatch.** Coordinating builds or deploys in other repositories requires a token scoped beyond the current repository.
-- **Writing to a protected trunk.** `GITHUB_TOKEN` cannot bypass branch protection, so it cannot push manifest state to a protected trunk branch. A PAT (or a GitHub App token) can bypass protection and produces a verified, signed commit.
-
-Reference your secrets by bare name. cascade wraps a bare name in a `${{ secrets.* }}` expression for you:
-
-```yaml
-ci:
- config:
- release_token: RELEASE_PAT
- state_token: STATE_PAT
-```
-
-:::caution[`release_token` defaults to `state_token`, and must be trigger-capable]
-The release token creates the rc tag, and that tag is what triggers the Release run, fleet validation, and promotion. GitHub deliberately suppresses workflow triggers for ref creations made with the default `GITHUB_TOKEN`, so an rc tag created with `GITHUB_TOKEN` fires nothing and the rc-to-release chain dies silently. To avoid that, an unset `release_token` inherits your `state_token` when one is set, reusing the trigger-capable token you already configured for protected-trunk writes. Whatever resolves as the release token must be trigger-capable (a PAT or a GitHub App token) for the automatic chain to run. If your state token is supplied solely through `state_token_app` (no static `state_token`), set a static `release_token` explicitly, since a minted App token is a run-time step output that this default cannot reach.
-:::
-
-#### GitHub App
-
-A GitHub App avoids storing a long-lived PAT. cascade mints a fresh installation token per run, scoped to the App's least-privilege permissions and short-lived by construction. Only the App private key is ever stored as a secret; no PAT lives in your secret store.
-
-One-time operator setup:
-
-1. Create a GitHub App in your organization (for example, under `my-org`).
-2. Generate a private key for the App and download the key file.
-3. Install the App on the repository (or repositories) cascade runs in.
-4. Add the App to the repository ruleset bypass list so it can write the protected trunk branch.
-5. Store the App ID and the private key as GitHub secrets, for example `CASCADE_APP_ID` and `CASCADE_APP_PRIVATE_KEY`. Store only the private key as a secret, never the raw key material in the manifest.
-
-Then point the manifest at those secrets with `release_token_app` and `state_token_app`. Each takes an `app_id` and a `private_key`, both secret references (a bare secret name or a `secrets`/`vars` expression):
-
-```yaml
-ci:
- config:
- release_token_app:
- app_id: CASCADE_APP_ID
- private_key: CASCADE_APP_PRIVATE_KEY
- state_token_app:
- app_id: CASCADE_APP_ID
- private_key: CASCADE_APP_PRIVATE_KEY
-```
-
-When an App source is set, the generated workflow mints a short-lived installation token at run time via the `actions/create-github-app-token` action, guarded to real GitHub with `if: ${{ github.server_url == 'https://github.com' }}`. The token consumers prefer the minted token.
-
-:::note[Local act/gitea is unaffected]
-On act or gitea the minting step is skipped (the `github.server_url` guard does not match), and the consumers fall back to the static `release_token` / `state_token`. Set both the App source and a static token if you run the same manifest locally and against real GitHub.
-:::
-
-### git Section
-
-Optional git identity and signing configuration for state commits:
-
-```yaml
-ci:
- config:
- git:
- mode: custom
- user_name: deploy-bot
- user_email: deploy@example.com
- gpg_key_id: GPG_KEY_ID
- gpg_key_secret: GPG_PRIVATE_KEY
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `mode` | string | default | `default`, `custom`, or `external` |
-| `user_name` | string | github-actions[bot] | Git user.name (when `mode: custom`) |
-| `user_email` | string | github-actions[bot]@users.noreply.github.com | Git user.email |
-| `gpg_key_id` | string | - | Secret name containing GPG key ID |
-| `gpg_key_secret` | string | - | Secret name containing GPG private key |
-
-**Modes:**
-- `default`: Use `github-actions[bot]` identity
-- `custom`: Use the supplied `user_name` and `user_email`
-- `external`: Skip git config entirely (assume pre-configured by the runner)
-
-**GPG signing:** When both `gpg_key_id` and `gpg_key_secret` are set, the framework imports the key, enables `commit.gpgsign`, and signs state commits.
-
-### validate Section
-
-Optional pre-build validation:
-
-```yaml
-ci:
- config:
- validate:
- workflow: .github/workflows/validate.yaml
- supports_dry_run: false
- triggers: [src/**]
- inputs:
- check_lint: true
- env_inputs:
- prod:
- check_security: true
- run_policy: default
- on_failure: abort
- retries: 0
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `workflow` | string | - | Path to validation workflow |
-| `supports_dry_run` | bool | false | Whether the callback handles `dry_run` input |
-| `triggers` | list | - | File patterns that should trigger validation |
-| `inputs` | map | {} | Static inputs passed to the workflow |
-| `env_inputs` | map | {} | Per-environment input overrides |
-| `run_policy` | string | default | Execution policy |
-| `on_failure` | string | abort | Failure handling |
-| `retries` | int | 0 | Retry attempts (0-3) |
-
-### builds Section
-
-Builds produce artifacts (Docker images, binaries, etc.):
-
-```yaml
-ci:
- config:
- builds:
- - name: app
- workflow: .github/workflows/build-app.yaml
- triggers: [src/**, Dockerfile]
- depends_on: []
- inputs:
- dockerfile: ./Dockerfile
- env_inputs:
- prod:
- sign_image: true
- run_policy: default
- on_failure: abort
- retries: 0
-```
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `name` | string | Yes | Unique build identifier |
-| `workflow` | string | Yes | Path to build workflow |
-| `triggers` | list | No | Glob patterns that trigger this build |
-| `depends_on` | list | No | Other callbacks to wait for |
-| `inputs` | map | No | Static inputs to workflow |
-| `env_inputs` | map | No | Per-environment input overrides |
-| `run_policy` | string | No | Execution policy |
-| `on_failure` | string | No | Failure handling |
-| `retries` | int | No | Retry attempts (0-3) |
-| `permissions` | map | No | `GITHUB_TOKEN` scopes for this callback's caller job |
-
-The build's `artifact_id` output (if declared) is captured automatically into state. Any other declared outputs are forwarded to dependent deploys as inputs.
-
-A `permissions` map is rendered as a job-level `permissions:` block on the caller job that invokes this callback, scoping the `GITHUB_TOKEN` to least privilege for that one job. GitHub Actions treats a job-level block as the **complete** permission set: it replaces the workflow default rather than merging with it. Declare the full set the callback needs, including `contents: read` if the callback checks out code and `id-token: write` for OIDC. cascade emits exactly the scopes you declare and never injects an implicit scope.
-
-```yaml
-permissions:
- contents: read
- id-token: write
-```
-
-### deploys Section
-
-Deploys target environments:
-
-```yaml
-ci:
- config:
- deploys:
- - name: infra
- workflow: .github/workflows/deploy-infra.yaml
- triggers: [cdk/**]
- depends_on: []
- supports_dry_run: true
- inputs:
- stack_name: my-stack
- env_inputs:
- prod:
- approval_required: true
- run_policy: default
- on_failure: abort
- retries: 0
-```
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `name` | string | Yes | Unique deploy identifier |
-| `workflow` | string | Yes | Path to deploy workflow |
-| `triggers` | list | No | Glob patterns that trigger this deploy |
-| `depends_on` | list | No | Other callbacks to wait for |
-| `supports_dry_run` | bool | No | Whether the callback handles `dry_run` |
-| `inputs` | map | No | Static inputs |
-| `env_inputs` | map | No | Per-environment overrides |
-| `run_policy` | string | No | Execution policy |
-| `on_failure` | string | No | Failure handling |
-| `retries` | int | No | Retry attempts (0-3) |
-| `permissions` | map | No | `GITHUB_TOKEN` scopes for this callback's caller job |
-
-As with builds, a deploy's `permissions` map is the complete permission set for its caller job (it replaces the workflow default, not merges). Include every scope the deploy needs, such as `contents: read` for checkout and `id-token: write` for OIDC.
-
-### Deploy Types
-
-Deploys are classified by their configuration:
-
-| Type | Configuration | When It Runs |
-|------|--------------|--------------|
-| **Trigger-based** | Has `triggers` | When matching files change |
-| **Build-linked** | Has `depends_on` referencing a build | When the referenced build runs |
-| **Unconstrained** | No `triggers` or `depends_on` | Always runs |
-
-Build-linked deploys inherit the build's triggers for change detection during promotions.
-
-### publish Section
-
-The publish callback runs once per build when a release is published, at the point where an RC version becomes a final semver. Use it to retag artifacts that still carry their RC version.
-
-```yaml
-ci:
- config:
- publish:
- workflow: .github/workflows/publish.yaml
-```
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `workflow` | string | Yes | Path to publish workflow (reusable, `workflow_call` trigger) |
-
-The callback is invoked once per configured build and receives:
-
-| Input | Type | Description |
-|-------|------|-------------|
-| `build_name` | string | Which build's artifacts to retag (e.g., `app`) |
-| `old_version` | string | RC version currently in the registry (e.g., `v1.0.0-rc.2`) |
-| `new_version` | string | Final semver to apply (e.g., `v1.0.0`) |
-| `sha` | string | Git commit SHA |
-| `artifact_id` | string | Immutable digest from the build's `artifact_id` output (if declared) |
-
-The framework only carries metadata. The publish workflow performs the registry operation.
-
-### external Section (Multi-Repo Orchestration)
-
-For repositories that coordinate deployments owned by satellite repos.
-
-`external:` is designed for the **satellite/sibling-repo artifact coordination** pattern: a separate repo (the satellite) owns its own build and deploys to its first environment, then notifies the primary via `workflow_dispatch`. The primary records the satellite's SHA and version in the shared manifest and includes the satellite's deploys in every subsequent promotion. `external:` is **not** a GitOps mirror mechanism. It does not push rendered manifests to a target repo or track a pushed commit in a foreign repo. The first-class (reserved) home for the GitOps mirror pattern is a deploy's `deploy_target:` block with `mode: gitops`, which reserves the shape for pushing a rendered field into a dedicated config repo and recording the pushed commit (see [Reserved shape: GitOps deploy target](./versioning#reserved-shape-gitops-deploy-target)).
-
-```yaml
-ci:
- config:
- external:
- - repo: org/cdk-infra
- ref: main
- deploys:
- - name: cdk
- workflow: .github/workflows/deploy-cdk.yaml
- triggers: [cdk/**]
- - repo: org/k8s-manifests
- deploys:
- - name: k8s
- workflow: org/k8s-manifests/.github/workflows/deploy.yaml@v1
- on_update:
- deploy:
- workflow: org/k8s-manifests/.github/workflows/deploy.yaml@v1
-```
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `repo` | string | Yes | External repository (e.g., `org/cdk-infra`) |
-| `ref` | string | No | Branch/tag reference (default: trunk_branch) |
-| `deploys` | list | Yes | Deployables from this repo |
-| `deploys[].name` | string | Yes | Unique deploy identifier |
-| `deploys[].workflow` | string | Yes | Workflow path (local or external) |
-| `deploys[].triggers` | list | No | File patterns for change detection |
-| `deploys[].on_update.deploy.workflow` | string | No | Reusable workflow to run as a scoped deploy when this slot is recorded |
-
-**Workflow paths:**
-- Local (`.github/workflows/deploy.yaml`) calls a workflow in the primary repo
-- External (`org/repo/.github/workflows/deploy.yaml@ref`) calls a workflow in the external repo
-
-When external deploys are configured, the generated promote workflow includes deploy jobs for each external deploy and the finalize job tracks their state.
-
-#### Deploy on update (opt-in)
-
-By default the receiver is record-only: when a satellite reports a new version, the primary records the new external state and stops. Setting `on_update.deploy.workflow` on an external deploy opts that component in to a scoped deploy that runs synchronously in the same receiver run, right after the slot is recorded.
-
-```yaml
-ci:
- config:
- external:
- - repo: org/cdk-infra
- ref: main
- deploys:
- - name: cdk
- workflow: org/cdk-infra/.github/workflows/deploy.yaml
- on_update:
- deploy:
- workflow: org/cdk-infra/.github/workflows/deploy.yaml
-```
-
-Behavior:
-
-- **Opt-in and additive.** Omit `on_update` and the receiver stays record-only, byte-for-byte identical to before. No deploy job is generated.
-- **Scoped to the updated component.** The generated receiver emits one `deploy_` job per opted-in component, each gated on `inputs.deploy_name` so a single receiver run deploys only the component that was just recorded. Other components are untouched.
-- **Synchronous and gated on the record.** The deploy job runs in the same receiver run and only after the record step succeeds. A failed record never triggers a deploy.
-- **Reusable-workflow only.** Like `deploys[].workflow`, `on_update.deploy` accepts a workflow path (local `.github/workflows/x.yaml` or `org/repo/.github/...@ref`); inline `run:` and `shell:` are not supported. The scoped deploy receives the recorded `environment`, `sha`, `version`, and `deploy_name` as inputs and inherits secrets.
-
-### notify Section (Satellite Repos)
-
-For satellite repositories that report deployments back to a primary repo:
-
-```yaml
-ci:
- config:
- notify:
- repo: org/my-backend
- workflow: external-update.yaml
- token: PRIMARY_REPO_TOKEN
- deploy_name: artifact-a
- environment: staging
-```
-
-| Field | Type | Required | Description |
-|-------|------|----------|-------------|
-| `repo` | string | Yes | Primary repository to notify |
-| `workflow` | string | No | Workflow name (default: `external-update.yaml`) |
-| `token` | string | No | Secret name for cross-repo dispatch (default: `PRIMARY_REPO_TOKEN`) |
-| `deploy_name` | string | No | Deploy name to dispatch. Set this when the primary recognizes this satellite under a name that differs from its local deploy/build name. Defaults to the first local deploy name, then the first build name. |
-| `environment` | string | No | Environment to dispatch. Set this when the primary expects an environment that differs from the satellite's first local environment (for example a build-only satellite with no environments). Defaults to the first local environment, then `dev`. |
-
-When configured, the orchestrate workflow's finalize job dispatches to the primary repo after deploying to the first environment.
-
-Use `deploy_name` and `environment` when the satellite's local names do not match the external deploy the primary defines. The primary validates the dispatched `deploy_name` and `environment` against its own config, so a satellite whose local build name or environment differs from what the primary expects must send the parent-recognized values here.
-
-**Important:** A repository cannot be both primary (has `external`) and satellite (has `notify`).
-
-### release Section
-
-```yaml
-ci:
- config:
- release:
- disabled: false
- tag: goreleaser.tag
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `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
-ci:
- config:
- changelog:
- disabled: false
- workflow: .github/workflows/custom-changelog.yaml
- contributors: true
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `disabled` | bool | false | Disable changelog generation entirely |
-| `workflow` | string | - | Path to a custom changelog workflow |
-| `contributors` | bool | false | Include contributor attribution via the GitHub API |
-
-Omit this section to use the built-in conventional commit parser.
-
-### Drift-check workflow (opt-in)
-
-Set `drift_check.enabled: true` and `generate-workflow` emits a pull-request workflow that runs [`cascade verify`](/cli-reference/#verify) and fails the check whenever the committed workflows fall out of sync with the manifest. This wires the same protection cascade uses on its own repository into yours, without hand-rolling the job.
-
-```yaml
-ci:
- config:
- drift_check:
- enabled: true
- comment: true
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `enabled` | bool | false | Emit the pull-request drift-check workflow (`.github/workflows/cascade-drift-check.yaml`) |
-| `comment` | bool | false | Also emit the fork-safe comment companion (`.github/workflows/cascade-drift-comment.yaml`) |
-
-Behavior:
-
-- **Opt-in and additive.** Omit `drift_check` and nothing is emitted; existing output is byte-for-byte identical to before.
-- **Read-only on the pull request.** The `cascade-drift-check.yaml` job triggers on `pull_request` with `contents: read` only. A pull request from a fork gets a read-only token and no secrets, so the job cannot comment or write. It captures the verify result as a `cascade-drift-result` artifact instead, and re-exits non-zero on drift to keep the check red.
-- **Fork-safe comment companion.** When `comment: true`, `cascade-drift-comment.yaml` triggers on `workflow_run` in the base-repo context, where it has a scoped `pull-requests: write` token. It downloads the artifact (data only), then posts or updates a sticky comment with the verify output. It never checks out or executes pull-request head code.
-- **Trusted PR resolution.** The companion derives the target pull-request number only from trusted `workflow_run` run metadata (the source run's `pull_requests` array, or a head-SHA lookup for fork pull requests), never from the artifact the pull-request job uploads. A fork therefore cannot redirect the comment at another pull request.
-- **cascade-owned.** Both files carry the cascade-generated marker, so `cascade verify` itself tracks them: edit them by hand and they are reported as drift; remove the toggle and they are reported as orphans.
-
-> **Pin recommendation.** When you enable `comment: true`, consider setting `pin_mode: sha`. The comment companion runs `actions/github-script` in a write-scoped `workflow_run` job, and the product default `pin_mode: tag` references that action by a floating major tag. Pinning to a full commit SHA removes the floating-tag exposure on the one job that holds a `pull-requests: write` token.
-
-### Reconcile companion (opt-in)
-
-Set `reconcile.enabled: true` and `generate-workflow` emits the fork-safe [`cascade reconcile`](/cli-reference/#reconcile) lane: a `pull_request` detector plus a `workflow_run` companion that adopts an external governed-pin change back into `action_pins` and regenerates, so a bump such as a merged Dependabot update lands in the manifest instead of drifting the generated workflow out from under it.
-
-```yaml
-ci:
- config:
- reconcile:
- enabled: true
- source: dependabot
- commit: append
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `enabled` | bool | false | Emit the reconcile detector (`.github/workflows/cascade-reconcile-check.yaml`) and companion (`.github/workflows/cascade-reconcile-companion.yaml`) |
-| `source` | string | `dependabot` | The change-source adapter this companion recognizes. The reconcile engine itself is source-agnostic; `dependabot` is the first adapter. |
-| `commit` | string | `append` | How the adoption commit is routed. `append` pushes onto the triggering pull request's own branch; `followup` opens a separate pull request instead, for repositories that automerge without further review. |
-
-Behavior:
-
-- **Opt-in and additive.** Omit `reconcile` and nothing is emitted; existing output is byte-for-byte identical to before.
-- **Read-only detector.** `cascade-reconcile-check.yaml` triggers on `pull_request` with `contents: read` only. It runs `cascade reconcile --check`, which decides relevance and writes the changed governed refs to a data-only `pin-reconcile-result` artifact; a fork pull request gets a read-only token and no secrets, so this job cannot push or comment.
-- **Base-definition companion.** `cascade-reconcile-companion.yaml` triggers on `workflow_run` in the base-repo context, where it holds a scoped `contents: write` / `pull-requests: write` token. It resolves the target pull request only from trusted `workflow_run` run metadata, downloads the detector's artifact as data, fetches the pull request's head files via the trusted `refs/pull//head` ref (never a checkout of a fork's own repository), and runs the pinned `cascade reconcile` binary to adopt the change.
-- **Commit routing.** `commit: append` (the default) pushes the adoption commit onto the triggering pull request's own branch, but only when that pull request is not a fork; a fork pull request always falls back to a sticky comment naming the refs to adopt by hand, since cascade cannot push to a fork's branch. `commit: followup` never touches the original branch: it commits to a cascade-owned `cascade-reconcile/pr-` branch and opens (or updates) a separate pull request against the same base, so an automerge-without-review pull request is never mutated in place.
-- **Loop guards.** The companion only pushes when `cascade reconcile` actually changed something, re-checks the branch's fresh tip before pushing and aborts rather than overwriting newer commits, and never force-pushes onto a shared branch.
-- **Automerge caveat.** See [Automerge caveat](#automerge-caveat) above: enabling this companion turns a would-be-red drift into a green check, so prefer `commit: followup` if your repository automerges once checks pass.
-- **Token scope.** The common case needs only `Contents: write` on the state token, because the source pull request already updated the generated workflow byte for byte and only the manifest changes; `Workflows: write` is needed only when a regenerate must also push updated workflow files.
-
-### Native deployments (opt-in)
-
-Set `deployments.enabled: true` and the finalize job reports deployment status through the [GitHub Deployments API](https://docs.github.com/en/rest/deployments/deployments). It creates a Deployment for the environment selected at run time, marks it `in_progress`, then reports a terminal `success` or `failure` status once the deploy callbacks finish. Pair it with a per-environment `environment_url` so the Deployment status links straight to the running environment.
-
-```yaml
-ci:
- config:
- environments: [production]
- deployments:
- enabled: true
- keep_prior_active: false
- environment_config:
- production:
- environment_url: "https://app.example.com"
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `deployments.enabled` | bool | false | Create a Deployment and report status from the finalize job |
-| `deployments.keep_prior_active` | bool | false | Set `auto_inactive: false` so GitHub leaves prior deployments for the same environment Active. Default relies on GitHub's native auto-inactivation |
-| `environment_config..environment_url` | string | "" | URL reported on the Deployment status for that environment |
-
-Behavior:
-
-- **Status transition model.** The finalize job runs after every deploy callback, so it owns the full lifecycle: create the Deployment, set `in_progress`, then set `success` or `failure` based on whether every deploy callback succeeded. The terminal status step runs under `always()` so a failed deploy still reports `failure` instead of leaving the Deployment stuck at `in_progress`.
-- **Per-environment URL.** `environment_url` is resolved at run time from `environment_config..environment_url` for the environment being deployed. Environments without a configured URL report an empty URL.
-- **Guarded to real GitHub.** Every Deployments API step carries an `if: ${{ github.server_url == 'https://github.com' }}` guard, so on act or gitea (which have no Deployments API) the steps are skipped and the workflow stays runnable.
-- **Least-privilege scope.** The toggle adds `deployments: write` to the workflow's top-level permissions only when enabled; the OFF-state output is unchanged.
-- **Opt-in and additive.** Omit `deployments` and nothing is emitted. The field did not bump `schema_version`.
-
-### Validate-check workflow (opt-in)
-
-Set `validate_check.enabled: true` and `generate-workflow` emits a lightweight `pull_request` workflow (`.github/workflows/cascade-validate.yaml`) that runs [`cascade parse-config`](/cli-reference/#parse-config) against the manifest and fails the check when the configuration is invalid, so a malformed manifest cannot merge to trunk.
-
-```yaml
-ci:
- config:
- validate_check:
- enabled: true
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `enabled` | bool | false | Emit the manifest-validation PR check (`.github/workflows/cascade-validate.yaml`) |
-
-The check validates cascade's own configuration only. It does not run the repository's build or test suites, requests `contents: read` alone, and has no dry-run or comment side effects.
-
-### Merge-queue workflow (opt-in)
-
-Set `merge_queue.enabled: true` and cascade emits a `merge_group`-triggered workflow (`.github/workflows/cascade-merge-queue.yaml`) that validates the prospective trunk commit: it runs `cascade parse-config` as a validity gate and a dry-run `cascade orchestrate setup` to preview the build and deploy decisions against the merge-group candidate ref.
-
-```yaml
-ci:
- config:
- merge_queue:
- enabled: true
-```
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `enabled` | bool | false | Emit the merge-queue validation lane (`.github/workflows/cascade-merge-queue.yaml`) |
-
-The lane is read-only: no state writes, no releases, no deploys. It reports a status the merge queue can require. This generator owns the lane behavior; the raw `merge_group` trigger itself is expressible separately under `extra_triggers.merge_group`, and the two are intentionally distinct.
-
-## State Section
-
-The `state` section tracks deployment state per environment plus a synthetic `release` slot. The framework manages it automatically. Do not hand-edit.
-
-### Structure
-
-```yaml
-ci:
- state:
- dev:
- sha: "abc123def456"
- version: "v1.2.0-rc.3"
- committed_at: "2026-01-15T10:30:00Z"
- committed_by: "github-actions[bot]"
- builds:
- app:
- sha: "abc123def456"
- built_at: "2026-01-15T10:25:00Z"
- built_by: "github-actions[bot]"
- artifact_id: "sha256:def456..."
- tags:
- image_tag: "abc123-1736923500"
- deploys:
- infra:
- sha: "abc123def456"
- deployed_at: "2026-01-15T10:30:00Z"
- deployed_by: "github-actions[bot]"
-
- test:
- sha: "abc123def456"
- version: "v1.2.0-rc.3"
- committed_at: "2026-01-15T14:00:00Z"
-
- prod:
- sha: "def789abc012"
- version: "v1.1.0"
-
- release:
- sha: "def789abc012"
- version: "v1.1.0"
- committed_at: "2026-01-14T09:00:00Z"
-
- latest_release:
- version: "v1.1.0"
- sha: "def789abc012"
- released_on: "2026-01-14T09:00:00Z"
- released_by: "octocat"
-```
-
-### Environment-Level Fields
-
-| Field | Description |
-|-------|-------------|
-| `sha` | Commit SHA promoted into this environment |
-| `version` | Semantic version tag (e.g., `v1.2.3-rc.0`) |
-| `committed_at` | ISO 8601 timestamp when code was committed/promoted |
-| `committed_by` | GitHub actor who triggered the commit/promotion |
-| `builds` | Per-build tracking (auto-populated) |
-| `deploys` | Per-deployable tracking (auto-populated) |
-| `external` | Per-external-deploy tracking (primary repos only) |
-
-### The `release` Slot
-
-The implicit `release` env tracks the most recently published (non-draft) GitHub release. Promotions to prod first cross the `release` boundary, where the breaking-change gate runs and the publish callback fires.
-
-### Per-Build Tracking
-
-```yaml
-builds:
- app:
- sha: "abc123"
- built_at: "2026-01-15T10:25:00Z"
- built_by: "github-actions[bot]"
- artifact_id: "sha256:def456..."
- tags:
- image_tag: "abc123-1736923500"
- version: "1.2.3"
-```
-
-| Field | Description |
-|-------|-------------|
-| `sha` | Commit SHA that was built |
-| `built_at` | ISO 8601 timestamp |
-| `built_by` | GitHub actor who triggered the build |
-| `artifact_id` | Immutable artifact identifier captured from the build's `artifact_id` output |
-| `tags` | Additional declared workflow outputs |
-
-`artifact_id` is the canonical identifier passed to the publish callback. Tags are populated from the build's other declared outputs.
-
-### Per-Deployable Tracking
-
-```yaml
-deploys:
- infra:
- sha: "abc123"
- deployed_at: "2026-01-15T10:30:00Z"
- deployed_by: "github-actions[bot]"
- tags:
- stack_version: "v2.1.0"
-```
-
-This enables diff-based change detection during promotions. Only deployables with actual file changes are redeployed.
-
-### External Deploy Tracking
-
-For primary repos coordinating satellites:
-
-```yaml
-ci:
- state:
- dev:
- sha: "abc123def456"
- external:
- cdk:
- repo: "org/cdk-infra"
- sha: "cdk789xyz"
- version: "v1.2.0"
- deployed_at: "2026-01-15T10:30:00Z"
- deployed_by: "github-actions[bot]"
- artifacts:
- image_tag: "cdk-abc123"
-```
-
-External state is updated when:
-1. A satellite repo dispatches to the primary's `external-update` workflow
-2. The promote workflow promotes external deploys to higher environments
-
-## Policy Fields
-
-### run_policy
-
-Controls when a callback executes:
-
-| Value | Behavior |
-|-------|----------|
-| `default` | Skip if any dependency was skipped |
-| `always` | Run if triggered, even if dependencies skipped |
-| `force` | Always run, ignore triggers and dependencies |
-
-### on_failure
-
-| Value | Behavior |
-|-------|----------|
-| `abort` | Fail the entire workflow |
-| `continue` | Let other callbacks proceed |
-
-### retries
-
-Number of retry attempts if the callback fails (0-3).
-
-## Trigger Patterns
-
-Triggers use glob patterns:
-
-| Pattern | Matches |
-|---------|---------|
-| `src/**` | All files under src/ recursively |
-| `*.go` | Go files in the root directory |
-| `**/*.yaml` | YAML files anywhere in the repo |
-| `Dockerfile` | Exact file match |
-| `cdk/*.ts` | TypeScript files directly in cdk/ (not recursive) |
-| `deploy/k8s/**` | All files under deploy/k8s/ |
-
-Special characters:
-- `*` matches any characters except `/`
-- `**` matches any path segments
-- `?` matches a single character
-
-## Input Inheritance
-
-Inputs flow from static to environment-specific:
-
-```yaml
-deploys:
- - name: services
- inputs:
- cluster: default-cluster
- region: us-east-1
- env_inputs:
- dev:
- cluster: dev-cluster
- prod:
- region: us-west-2
-```
-
-For `dev`: `{ cluster: "dev-cluster", region: "us-east-1" }`
-For `prod`: `{ cluster: "default-cluster", region: "us-west-2" }`
-
-## Complete Example
-
-```yaml
-# .github/manifest.yaml
-ci:
- config:
- trunk_branch: master
- environments: [dev, test, prod]
- cli_version: v2.0.4
-
- validate:
- workflow: .github/workflows/validate.yaml
- inputs:
- run_tests: true
- run_policy: default
- on_failure: abort
-
- builds:
- - name: app
- workflow: .github/workflows/build-app.yaml
- triggers: [src/**, Dockerfile, go.mod, go.sum]
- inputs:
- dockerfile: ./Dockerfile
- env_inputs:
- prod:
- sign_image: true
- retries: 1
-
- - name: wiremock
- workflow: .github/workflows/build-wiremock.yaml
- triggers: [wiremock/**, wiremock.Dockerfile]
-
- deploys:
- - name: cdk
- workflow: .github/workflows/deploy-cdk.yaml
- triggers: [cdk/**, cdk.json]
- supports_dry_run: true
- on_failure: abort
-
- - name: services
- workflow: .github/workflows/deploy-services.yaml
- triggers: [src/**, deploy/**]
- depends_on: [cdk]
- inputs:
- cluster: default
- env_inputs:
- dev:
- cluster: dev-cluster
- test:
- cluster: test-cluster
- prod:
- cluster: prod-cluster
- retries: 2
-
- publish:
- workflow: .github/workflows/publish.yaml
-
- changelog:
- contributors: true
-```
diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md
deleted file mode 100644
index cd373da8..00000000
--- a/docs/src/content/docs/getting-started.md
+++ /dev/null
@@ -1,355 +0,0 @@
----
-title: Getting Started
-description: A step-by-step guide to installing the cascade CLI, creating a manifest, wiring callback workflows, and generating orchestration workflows in your repository.
----
-
-This guide walks through setting up `cascade` in your repository. For the big picture first, read the [Stage Graph](/cascade/stage-graph/) to see how trunk, your environments, and the release boundary fit together.
-
-## Prerequisites
-
-- Go 1.26.4+ (for the CLI)
-- A GitHub repository with Actions enabled
-- Trunk-based development (single primary branch)
-
-## Step 1: Install the CLI
-
-```bash
-# Install latest stable release
-go install github.com/stablekernel/cascade/cmd/cascade@latest
-
-# Install bleeding edge from master
-go install github.com/stablekernel/cascade/cmd/cascade@master
-
-# Install a specific version
-go install github.com/stablekernel/cascade/cmd/cascade@v2.0.4
-
-# Verify
-cascade version
-```
-
-In GitHub Actions, generated workflows install the CLI for you via the setup action, so you don't need to add it explicitly. To pin a version, set `cli_version` in your manifest.
-
-If you need to invoke it manually:
-
-```yaml
-- uses: stablekernel/cascade/.github/actions/setup-cli@master
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- # version: latest # or 'beta', or a specific version like 'v2.0.4'
-```
-
-The setup action downloads the release archive (`tar.gz`) from GoReleaser and installs the `cascade` binary on `PATH`.
-
-## Fast path: scaffold with `cascade init`
-
-If you want a working configuration in one step, run `cascade init`. It renders the manifest and the callback workflow stubs for you, verifies them through the real generator, and writes them into your repository:
-
-```bash
-# Two-environment pipeline (dev, prod) in the current directory
-cascade init --topology two-env
-
-# Or choose your own ordered environments; the last is the release stage
-cascade init --envs staging,production --name my-service
-
-# Preview without writing anything
-cascade init --topology two-env --dry-run
-```
-
-This produces `.github/manifest.yaml` plus build and deploy stubs under `.github/workflows`. The manifest already carries a `$schema` directive, so your editor gives you autocomplete and validation while you fill in the stubs. If a target file already exists, `init` aborts and lists the conflicts unless you pass `--force`.
-
-Once scaffolded, skip ahead to [Step 3](#step-3-create-callback-workflows) to fill in the callbacks, then generate the orchestration workflows. The manual walkthrough below covers the same files step by step if you would rather build them yourself.
-
-## Step 2: Create the manifest
-
-Create `.github/manifest.yaml` in your repository:
-
-```yaml
-ci:
- config:
- trunk_branch: master
- environments: [dev, test, prod]
- cli_version: v2.0.4
-
- # Optional pre-build validation
- validate:
- workflow: .github/workflows/validate.yaml
-
- builds:
- - name: app
- workflow: .github/workflows/build-app.yaml
- triggers:
- - "src/**"
- - "Dockerfile"
- - "go.mod"
-
- deploys:
- - name: infra
- workflow: .github/workflows/deploy-infra.yaml
- triggers:
- - "infra/**"
-
- - name: services
- workflow: .github/workflows/deploy-services.yaml
- depends_on: [app] # waits for build-app to succeed
-
- # Optional: retag artifacts when an RC is published as final
- publish:
- workflow: .github/workflows/publish.yaml
-
- changelog:
- contributors: true
-
- state:
- dev: {}
- test: {}
- prod: {}
-```
-
-The framework owns `state:` and `latest_release:`. The `state: { dev: {}, ... }` skeleton is enough. The workflows fill in the details on every run.
-
-See [Configuration Reference](/cascade/configuration/) for every field.
-
-### No-environment mode
-
-For library/CLI projects that publish releases without environment deployments, omit `environments`:
-
-```yaml
-ci:
- config:
- trunk_branch: master
- cli_version: v2.0.4
- builds:
- - name: cli
- workflow: .github/workflows/build-cli.yaml
- triggers: [cmd/**, internal/**, go.mod]
- changelog:
- contributors: true
-```
-
-Commits create RC pre-releases automatically; a `promote` dispatch (default mode) publishes the final release.
-
-## Step 3: Create Callback Workflows
-
-The framework calls your workflows. Create them following the [Callback Contract](/cascade/callback-contract/).
-
-### Build Workflow Example
-
-`.github/workflows/build-app.yaml`:
-
-```yaml
-name: Build App
-
-on:
- workflow_call:
- inputs:
- environment:
- type: string
- required: true
- sha:
- type: string
- required: true
- dry_run:
- type: boolean
- required: false
- default: false
- outputs:
- artifact_id:
- description: Immutable artifact identifier (e.g., image digest)
- value: ${{ jobs.build.outputs.artifact_id }}
- image_tag:
- description: Docker image tag
- value: ${{ jobs.build.outputs.image_tag }}
-
-jobs:
- build:
- runs-on: ubuntu-latest
- outputs:
- artifact_id: ${{ steps.push.outputs.digest }}
- image_tag: ${{ steps.meta.outputs.tag }}
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ inputs.sha }}
-
- - name: Generate tag
- id: meta
- run: |
- TAG="${{ github.sha }}-$(date +%s)"
- echo "tag=$TAG" >> "$GITHUB_OUTPUT"
-
- - name: Build image
- run: docker build -t myrepo/app:${{ steps.meta.outputs.tag }} .
-
- - name: Push image
- id: push
- if: ${{ !inputs.dry_run }}
- run: |
- docker push myrepo/app:${{ steps.meta.outputs.tag }}
- DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' \
- myrepo/app:${{ steps.meta.outputs.tag }} | cut -d@ -f2)
- echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
-```
-
-### Deploy Workflow Example
-
-`.github/workflows/deploy-services.yaml`:
-
-```yaml
-name: Deploy Services
-
-on:
- workflow_call:
- inputs:
- environment:
- type: string
- required: true
- sha:
- type: string
- required: true
- image_tag:
- type: string
- required: true
- dry_run:
- type: boolean
- required: false
- default: false
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- environment: ${{ inputs.environment }}
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ inputs.sha }}
-
- - name: Deploy
- if: ${{ !inputs.dry_run }}
- run: |
- echo "Deploying ${{ inputs.image_tag }} to ${{ inputs.environment }}"
- # Your deployment logic
-```
-
-### Publish Workflow Example
-
-If you configured `publish:` in the manifest, create the callback. It runs once per build when an RC is published as a final release:
-
-```yaml
-name: Publish
-
-on:
- workflow_call:
- inputs:
- build_name:
- type: string
- required: true
- old_version:
- type: string
- required: true # e.g., v1.0.0-rc.2
- new_version:
- type: string
- required: true # e.g., v1.0.0
- sha:
- type: string
- required: true
- artifact_id:
- type: string
- required: false # immutable digest if your build declares it
-
-jobs:
- retag:
- runs-on: ubuntu-latest
- steps:
- - name: Retag image
- run: |
- docker pull myrepo/${{ inputs.build_name }}:${{ inputs.old_version }}
- docker tag \
- myrepo/${{ inputs.build_name }}:${{ inputs.old_version }} \
- myrepo/${{ inputs.build_name }}:${{ inputs.new_version }}
- docker push myrepo/${{ inputs.build_name }}:${{ inputs.new_version }}
-```
-
-## Step 4: Generate Orchestration Workflows
-
-The CLI generates orchestration and promotion workflows from the manifest:
-
-```bash
-# Preview
-cascade generate-workflow --dry-run
-
-# Write the files
-cascade generate-workflow --force
-```
-
-This creates:
-- `.github/workflows/orchestrate.yaml` runs on merge to trunk
-- `.github/workflows/promote.yaml` handles manual promotion between environments
-
-## Step 5: Validate
-
-```bash
-cascade parse-config
-```
-
-Validates the manifest and prints any errors.
-
-## Step 6: Commit and Push
-
-```bash
-git add .github/manifest.yaml .github/workflows/
-git commit -m "feat: add trunk-based CI/CD"
-git push origin master
-```
-
-The orchestrate workflow runs automatically on the next merge.
-
-## What Happens Next
-
-1. **On every merge to trunk:**
- - Framework detects which files changed
- - Runs validation (if configured)
- - Triggers relevant builds and deploys
- - Updates state in `.github/manifest.yaml`
- - Creates/updates a draft pre-release with the changelog
-
-2. **To promote to test:**
- - Actions -> Promote workflow
- - Select `dev-to-test`
- - Run
-
-3. **To promote to prod:**
- - Actions -> Promote workflow
- - Select `test-to-prod` (or `dev-to-prod` for full cascade)
- - Run
-
- The release is published, the publish callback fires, and a git tag is created.
-
-## Common Issues
-
-### Workflow not triggering
-
-- Branch name matches `trunk_branch` in the manifest
-- Trigger patterns match the changed files
-- Workflow file is in `.github/workflows/`
-
-### Callback not found
-
-- Workflow path in the manifest matches the actual file path
-- Workflow has `on: workflow_call`
-- Required inputs/outputs are declared
-
-### Permission errors
-
-The generated workflows include the necessary permissions. If you wrap them in your own workflow, ensure:
-
-```yaml
-permissions:
- contents: write
- actions: write # promote dispatches release builds
-```
-
-## Next Steps
-
-- [Configuration Reference](/cascade/configuration/) for every field
-- [Callback Contract](/cascade/callback-contract/) for callback inputs/outputs
-- [Workflows](/cascade/workflows/) for generated workflow internals
diff --git a/docs/src/content/docs/guides/adopt.md b/docs/src/content/docs/guides/adopt.md
new file mode 100644
index 00000000..c79bbc66
--- /dev/null
+++ b/docs/src/content/docs/guides/adopt.md
@@ -0,0 +1,107 @@
+---
+title: Adopt an existing pipeline
+description: Map an existing CI/CD pipeline onto cascade, keep the tools you already use, and split a monolithic workflow into callbacks.
+---
+
+This guide is for a repository that already ships code through some pipeline today. It shows how to map your existing stages onto cascade's callbacks, keep tools like release-please or goreleaser, and split a monolithic workflow apart.
+
+## When to adopt vs start fresh
+
+If you have no pipeline yet, skip this page. Go straight to [Getting Started](/cascade/start/getting-started/) and build a green-field pipeline there; re-walking that tutorial here would just duplicate it.
+
+Come back to this page once you have:
+
+- An existing lint/test, build, or deploy job in GitHub Actions (or another CI system).
+- A release tool such as release-please, git-cliff, or goreleaser you want to keep.
+- A single monolithic workflow that does everything in one pass, which you need to split before cascade can orchestrate it.
+
+## Map your existing stages to callbacks
+
+cascade owns orchestration: promotion, state, versioning, and the release boundary. You keep the verbs: build, deploy, validate, and publish stay your logic, each supplied as a `workflow_call` reusable workflow that cascade calls with a fixed input contract.
+
+| You have today | In cascade | What changes |
+|----------------|-----------|--------------|
+| A lint/test job | A **validate** callback | Move the checks into a `workflow_call` workflow; cascade calls it with `environment`, `sha`, `dry_run` before build. If validate runs in the same pass as build and deploy today, see [Split a monolithic pipeline](#split-a-monolithic-pipeline). |
+| A build/package step | A **build** callback | Move the step into a `workflow_call` workflow; declare `artifact_id` (and any tags) as outputs so they chain to deploys and to publish. |
+| A deploy script or job | A **deploy** callback | Move the script into a `workflow_call` workflow; cascade calls it with `environment`, `sha`, `dry_run`, plus the build's declared outputs. If build and deploy are fused today, see [Split a monolithic pipeline](#split-a-monolithic-pipeline). |
+| A release-tagging or retag step | A **publish** callback | Move it into a `workflow_call` workflow; cascade calls it once per build at the release boundary with `build_name`, `old_version`, `new_version`, `sha`, `artifact_id`. |
+| Hand-rolled env-promotion scripts | cascade's promotion chain | Delete your promotion glue. cascade orchestrates, pins SHAs, and gates the release boundary for you. |
+| Manual or tool-driven version bumping | Conventional-commit-driven version derivation | Your bump logic goes away; commit messages drive the semver, and this is required, not optional. |
+| A changelog tool (release-please, git-cliff) | A **changelog** callback, or keep the tool standalone | See [Keep release-please or git-cliff](#keep-release-please-or-git-cliff). |
+| A release tool (goreleaser) | An external release via `release.tag`, or keep the tool standalone | See [Keep goreleaser](#keep-goreleaser). |
+
+Every callback is a reusable workflow with an `on: workflow_call` trigger; see the [Callback contract](/cascade/reference/callbacks/) for the full input/output shape of each callback type. A minimal manifest expressing the mapping above:
+
+```yaml
+project: my-service
+schema_version: 1
+trunk_branch: main
+cli_version: v0.9.1
+
+environments: [dev, staging, prod]
+
+validate:
+ workflow: .github/workflows/validate.yaml
+
+builds:
+ - name: app
+ workflow: .github/workflows/build-app.yaml
+ triggers: ["src/**", "Dockerfile", "go.mod"]
+
+deploys:
+ - name: app
+ workflow: .github/workflows/deploy-app.yaml
+ depends_on: [app]
+```
+
+## Keep release-please or git-cliff
+
+Two supported paths, both configured under `changelog:` in the [manifest reference](/cascade/reference/manifest/):
+
+- **Wrap it as a callback.** Point `changelog.workflow` at a reusable workflow that wraps your tool. cascade passes `changelog_base_sha`, `head_sha`, and `repo`; your workflow returns a `changelog` output that cascade uses when it cuts the release.
+- **Disable and keep it standalone.** Set `changelog.disabled: true`. Your existing changelog workflow keeps running on its own trigger; cascade stops generating a changelog, and everything else (promotion, release) still works.
+
+## Keep goreleaser
+
+cascade has no dedicated "release callback" that receives `build_name`/`old_version`/`new_version`. Releasing is either cascade's own job or your external tool, configured under `release:`:
+
+- **External release tool.** Keep your goreleaser step as a normal build or deploy callback that emits a tag output, then set `release.tag` to that `callback.output` reference. cascade defers the tag to your tool's output.
+- **Disable and keep goreleaser standalone.** Set `release.disabled: true` to turn off cascade's release management and run goreleaser on its own trigger.
+
+Omitting `release:` entirely uses cascade's defaults: it creates releases with conventional-commit changelogs.
+
+## Split a monolithic pipeline
+
+A common starting point is one workflow or job that lints, tests, builds, deploys, and tags a release in a single pass, often rebuilding the artifact at every environment. cascade cannot orchestrate that shape directly, so splitting it apart is the main adaptation work of adopting cascade.
+
+cascade calls each stage separately: validate, then build, then deploy, then (at the release boundary) publish. Split your monolith along those seams:
+
+- **Validate** (the lint/test portion) becomes its own callback that cascade runs before build.
+- **Build** and **deploy** become separate callbacks. This split is load-bearing, not stylistic: cascade builds the artifact once, on the first environment, and promotes that same SHA-pinned artifact through every later environment, running only deploy there. A build step still living inside deploy would rebuild at every environment and break that guarantee.
+- **Publish** (the release-tagging or retag step) becomes its own callback that fires once per build at the release boundary.
+
+To make the build/deploy split:
+
+1. **Extract the build** into its own `workflow_call` workflow that declares its artifact identifier (`artifact_id`, and any tag like `image_tag`) under `on.workflow_call.outputs`. cascade captures `artifact_id` into state and forwards declared outputs to dependent deploys.
+2. **Extract the deploy** into its own `workflow_call` workflow that receives that identifier as an input of the same name. With `depends_on: []`, cascade chains the build's outputs into the deploy automatically.
+3. **Stop rebuilding in deploy.** Remove any build steps from the deploy path. The deploy consumes the artifact identifier it is handed; the GitHub Environment gate lives on the job inside this deploy workflow, not on the caller cascade generates.
+
+The result is the same logic you have today, separated along the seams cascade promotes and releases across. See the [Callback contract](/cascade/reference/callbacks/) for full validate, build, deploy, and publish skeletons.
+
+## Choose a topology
+
+Environment count is structural, and positional: the last environment is always the release stage, the second-to-last is the prerelease stage. Pick the shape that matches your project:
+
+| Topology | Environments | Fits |
+|----------|---------------|------|
+| No-env | `environments` omitted | Libraries and CLIs that release without deploying anywhere. |
+| 2-env | `dev`, `prod` | Smallest promotion chain with a prerelease stage. |
+| 3-env | `dev`, `staging`, `prod` | The common default. |
+| 4-env | `dev`, `staging`, `pre`, `prod` | Adds a dedicated prerelease stage before production. |
+
+`cascade init --topology ` scaffolds any of these for you, or use `--envs` for a custom ordered list (for example `--envs staging,production`). See [Add or change environments](/cascade/guides/environments/) once your topology is running and you need to reshape it.
+
+---
+
+**Prerequisite:** [Getting Started](/cascade/start/getting-started/) for install and a first pipeline.
+**Next:** [Add or change environments](/cascade/guides/environments/).
diff --git a/docs/src/content/docs/guides/environments.md b/docs/src/content/docs/guides/environments.md
new file mode 100644
index 00000000..9ae54733
--- /dev/null
+++ b/docs/src/content/docs/guides/environments.md
@@ -0,0 +1,116 @@
+---
+title: Add or change environments
+description: Reshape your environment chain, wire per-environment GitHub Environment settings, and apply branch protection.
+---
+
+This guide covers adding an environment to an existing pipeline, configuring each environment (required reviewers, wait timers, branch policy), and applying those settings to GitHub.
+
+## Add an environment
+
+`environments` is an ordered list; position, not name, carries meaning. The last environment is the release stage, the second-to-last is the prerelease stage. Add a name at the position you want, then regenerate:
+
+```yaml
+environments: [dev, staging, prod]
+```
+
+```bash
+cascade generate-workflow -f
+```
+
+cascade adds the new environment's `state.` entry automatically the next time orchestrate or promote finalizes; you never hand-author `state:`. Appending to the end of the list shifts which environment is the release stage, since that role is always the last position, so reorder deliberately rather than just appending if you want to keep an existing environment as production.
+
+## Per-environment config
+
+`environment_config.` carries settings for one environment, keyed by its cascade name. All fields are optional and additive.
+
+```yaml
+environment_config:
+ prod:
+ gha_environment: production
+ required_reviewers: ["octocat", "team/ops"]
+ wait_timer: 10
+ branch_policy: protected
+```
+
+| Field | Purpose |
+|-------|---------|
+| `gha_environment` | Maps this cascade environment to a GitHub Environment: native deployment records, `environment_url`, required reviewers, wait timers, env-scoped secrets. |
+| `required_reviewers` | User or team slugs (for example `octocat`, `team/ops`) that may approve a deployment. |
+| `wait_timer` | Delay in minutes before a job targeting this environment runs. GitHub accepts 0 to 43200 (30 days). |
+| `branch_policy` | `protected` (protected branches only), `custom` (only branches/tags matching patterns), or `all` (no restriction). Empty is treated as `all`. |
+
+### Full field reference
+
+A few more fields round out the block, mostly for the `custom` branch policy or operator record-keeping:
+
+| Field | Purpose |
+|-------|---------|
+| `branch_patterns` | Branch name patterns allowed to deploy when `branch_policy: custom`. |
+| `tag_patterns` | Tag name patterns allowed to deploy when `branch_policy: custom`. |
+| `secrets` | Expected env-scoped secret **names** only. cascade never stores or emits values; create them out of band. |
+| `variables` | Expected env-scoped variable **names** only, same rule as secrets. |
+| `environment_url` | URL reported to the GitHub Deployments API for this environment's deployment status. |
+
+GitHub Environment support is shipped: `gha_environment` drives native GitHub deployments and `environment_url`, and the fields above feed the `environments` command below. It lands in generated output today, not a "modeled but not emitted" state.
+
+## Apply GitHub Environment settings with the `environments` command
+
+`cascade environments` reads your manifest and emits a per-environment configuration file for an operator to apply. cascade never calls the GitHub API itself:
+
+```bash
+cascade environments
+```
+
+The output has one entry per manifest environment, in manifest order:
+
+| Key | Contents |
+|-----|----------|
+| `environments[].name` | The cascade environment name. |
+| `environments[].gha_environment` | The GitHub Environment to configure. |
+| `environments[].environment` | The body to `PUT` to the Environments API (`wait_timer` and the branch-policy fields cascade can fully form). |
+| `environments[].operator_todo` | Guidance that is not part of the API body: reviewer slugs to resolve to numeric IDs, and secret/variable names to create. |
+
+Apply each entry by sending only its `.environment` object:
+
+```bash
+cascade environments | jq -c '.environments[] | {gha_environment, environment}' | \
+ while read -r row; do
+ env=$(jq -r .gha_environment <<<"$row")
+ jq .environment <<<"$row" | \
+ gh api -X PUT "repos/OWNER/REPO/environments/$env" --input -
+ done
+```
+
+Required reviewers need a manual step: the Environments API takes numeric reviewer IDs, not slugs, so resolve each slug under `operator_todo.required_reviewers` and add it to the body's `reviewers` array yourself. Secrets and variables are names only under `operator_todo`; create them through the environment-secrets and environment-variables APIs and set values yourself.
+
+Flags: `-c/--config` (auto-detects `.github/manifest.yaml`), `--manifest-key`, `-o/--output` (write to a file instead of stdout).
+
+## Branch protection
+
+`cascade branch-protection` emits the JSON body for GitHub's branch-protection API, built from the required Setup and Finalize jobs that run on every pipeline run:
+
+```bash
+cascade branch-protection --branch main
+```
+
+The output has two keys: `protection` (the exact PUT body) and `operator_todo`. Apply it the same way:
+
+```bash
+cascade branch-protection | jq .protection | \
+ gh api -X PUT repos/OWNER/REPO/branches/main/protection --input -
+```
+
+The required-status-checks list only ever contains the cascade-controlled Setup and Finalize jobs, since those are the only contexts cascade knows the exact name of; applying `.protection` as-is never blocks a pull request. Your validate, build, and deploy callback jobs are not required automatically, because cascade knows their display-name prefix but not the inner job name GitHub appends to form the real check-run context. Complete those from `operator_todo.complete_these_contexts`, which lists `" / "` placeholders for you to fill in.
+
+Pass `--apply` to PUT the body directly instead of emitting JSON; it requires a scoped, repo-admin token via `--token` (or `GITHUB_TOKEN`), plus `--repo` (default `GITHUB_REPOSITORY`) and `--api-url` (default `GITHUB_API_URL`, then `https://api.github.com`).
+
+## The runner-override caveat
+
+Per-environment (and per-callback) `runs_on` is validated-only: cascade parses and schema-checks it, but never emits it into generated YAML. GitHub Actions forbids a `runs-on:` key on a job that calls a reusable workflow with `uses:`, and every cascade-generated caller job is exactly that shape. So cascade-owned jobs are hardcoded to `ubuntu-latest`, and there is no way to point one environment's orchestration job at a different runner.
+
+Your own callback workflows are unaffected: set `runs-on:` inside your `workflow_call` workflow's job as you would in any other GitHub Actions workflow. The restriction only applies to the caller job cascade generates.
+
+---
+
+**Prerequisite:** [Getting Started](/cascade/start/getting-started/) for a running pipeline to reshape.
+**Next:** [Promote a release](/cascade/guides/promote/).
diff --git a/docs/src/content/docs/guides/hotfix.md b/docs/src/content/docs/guides/hotfix.md
new file mode 100644
index 00000000..4a288cb4
--- /dev/null
+++ b/docs/src/content/docs/guides/hotfix.md
@@ -0,0 +1,65 @@
+---
+title: Run a hotfix
+description: Patch an environment pinned to an older trunk base without dragging in every commit between its base and the fix.
+---
+
+A hotfix applies one or more trunk commits onto an environment that is pinned to an older trunk base, without advancing it past every intervening commit. Reach for it only when an environment must run exactly `base + fix` and nothing else; the standard promote flow cannot express that. This guide covers the operator path. For the generated `cascade-hotfix.yaml` job structure, see [Hotfix and rollback workflows](/cascade/reference/generated-workflows/#hotfix-and-rollback-workflows).
+
+## What a hotfix does (roll-forward first)
+
+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, merge to trunk and run a normal promotion instead; nothing diverges. The hotfix path is for the narrower case where the environment cannot take those other commits yet.
+
+## Start a hotfix
+
+Dispatch `cascade-hotfix.yaml` with the fix commit(s) and the target environment:
+
+```bash
+gh workflow run cascade-hotfix.yaml \
+ -f commit= \
+ -f target_env=test
+```
+
+The `plan` job fetches env branches and tags and runs `cascade hotfix plan`. The `apply` job then cherry-picks the fix onto a per-environment integration branch and opens a resolution pull request labeled `cascade-hotfix` (or `cascade-hotfix-conflict` if the cherry-pick collides). Merging that pull request runs build, deploy, and finalize, which write the diverged state.
+
+## Environment branches and the stale-branch self-heal
+
+When an environment needs to diverge, the fix is staged on `env/` (for example `env/test`), created on demand at the environment's recorded state SHA. The cherry-pick itself lands on `hotfix//`, based on `env/`.
+
+Before staging a new cherry-pick, the plan reconciles `env/` against the environment's recorded state SHA. Three outcomes:
+
+- **Absent**: created fresh at the recorded SHA.
+- **Tip matches**: left untouched.
+- **Tip has drifted**: cascade force-resets it back to the recorded SHA, but only when both hold: the environment is not already recorded as diverged, and a real single-flight check against the repository finds no open `cascade-hotfix` or `cascade-hotfix-conflict` pull request. If either check fails, the plan aborts rather than risk discarding in-flight work.
+
+Pass `--repo owner/repo` to enable the single-flight check; without it, a stale tip aborts the run instead of self-healing. `--dry-run` reports the plan without acting on it.
+
+## Elevation
+
+A hotfix can target an environment higher in the chain than the one immediately above the first. cascade elevates the fix bottom-up across every environment between the first and the target, skipping any environment where the commit is already present. Every commit applied to an environment is recorded in that environment's `patches`, so elevation is cumulative, not just the first hop. The first environment is never a hotfix target: a fix reaches it only by merging to trunk.
+
+On conflict, the chain halts at that environment; the pull request body lists which environments are still pending. Resolve the conflict by checking out the working branch, fixing it, and force-pushing:
+
+```bash
+git fetch && git switch hotfix//
+# resolve conflicts, then
+git push --force-with-lease
+```
+
+Re-dispatch targeting the same environment afterward to resume the chain from where it stopped.
+
+## Version grammar
+
+A hotfix allocates its own version segment so it sorts correctly relative to the rc sequence it interrupts. See [Hotfix version grammar](/cascade/reference/versioning/#hotfix-version-grammar) for the full derivation; the short form is `-rc.N.hotfix.M` for an unpublished (rc) base, or the next free patch for an already-published base.
+
+## What to watch
+
+- **plan** surfaces branch-protection suggestions as `::notice::` lines when `env/*` has no required checks configured; cascade never creates protection rules itself.
+- **The resolution pull request** is the audit record even when no human touches it. It merges as the configured `state_token`, not `GITHUB_TOKEN`, because a `GITHUB_TOKEN` merge does not emit the event that triggers build/deploy/finalize.
+- **A diverged environment blocks normal promotion** until the divergence clears. Promotion into it later requires the incoming trunk SHA to contain every recorded patch; dropping one is refused unless explicitly forced.
+- **Prod is a valid target.** The deploy job binds to the GitHub `environment:` of the target, so required reviewers and wait timers apply exactly as they do for a normal promotion.
+
+## Wayfinding
+
+**Prerequisite:** [Promote a release](/cascade/guides/promote/) for the normal path a hotfix is the exception to.
+
+**Next:** [Roll back an environment](/cascade/guides/rollback/) for undoing a bad deploy instead of patching one forward.
diff --git a/docs/src/content/docs/guides/multi-repo.md b/docs/src/content/docs/guides/multi-repo.md
new file mode 100644
index 00000000..6e7f83e3
--- /dev/null
+++ b/docs/src/content/docs/guides/multi-repo.md
@@ -0,0 +1,76 @@
+---
+title: Coordinate multiple repos
+description: Let a primary repo own the environment chain for artifacts built and versioned in satellite repos.
+---
+
+Some pipelines span more than one repository: a backend service plus a CDK stack, a set of Kubernetes manifests, or a Terraform module, each built and versioned on its own. Cascade lets one repo, the primary, own the environment state machine and promotion chain for all of them, while satellites report in after they deploy.
+
+## The multi-repo model in brief
+
+A satellite repo builds and deploys its own artifact to `dev` using its own orchestrate workflow, then notifies the primary. The primary records that deploy in its own manifest state and carries it through the rest of its environment chain alongside its local deploys, so one promotion advances every source together.
+
+```mermaid
+flowchart BT
+ satA["Satellite A"] -- "notify after dev deploy" --> primary
+ satB["Satellite B"] -- "notify after dev deploy" --> primary
+
+ primary["Primary repo Owns environment state machine Coordinates all promotions Tracks external deploy state"]
+```
+
+This is a coordination model, not a merge of repos: each satellite keeps its own history, callbacks, and release cadence. See [Architecture](/cascade/internals/architecture/) for the full design, including generated file sets on each side.
+
+## `external` callbacks and the `external update` command
+
+On the primary, the manifest's `external` field lists the satellite repos it coordinates and, for each, the deployables it expects to hear about. Each deployable can point at a local workflow or reference the satellite's workflow directly with `workflow: org/repo/.github/workflows/.yaml@ref`. Field-level detail lives in the [manifest reference](/cascade/reference/manifest/).
+
+Configuring `external` makes `generate-workflow` emit `external-update.yaml` on the primary. That workflow's job is to run `cascade external update`, which a satellite's own generated workflow dispatches after it deploys to `dev`:
+
+```bash
+cascade external update \
+ --source-repo org/cdk-infra \
+ --deploy-name cdk \
+ --environment dev \
+ --sha abc123 \
+ --version v1.2.0 \
+ --artifacts '{"image_tag": "cdk-abc123"}'
+```
+
+`external` is a parent command; `--gha-output` and `--manifest-key` are flags persistent across it and its subcommands, not `update`-only flags:
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--config` | auto-detect | Path to the primary's manifest file. |
+| `--manifest-key` | `ci` | Top-level key inside the manifest. |
+| `--gha-output` | `false` | Write outputs to `$GITHUB_OUTPUT` when run inside Actions. |
+
+`update`'s own flags are `--source-repo`, `--deploy-name`, `--environment`, and `--sha` (all required), plus optional `--version` and `--artifacts` (a JSON object). The command validates that the caller is a configured primary, that the named external deploy and source repo match the manifest, and that the target environment exists, before committing the new state and pushing.
+
+## `notify`
+
+On the satellite side, the manifest's `notify` field is the mirror image: it names the primary repo to dispatch to once this repo's own orchestrate workflow finishes deploying to its first environment. Configuring it makes the satellite's generated orchestrate workflow dispatch to the primary automatically; you do not call `cascade external update` yourself.
+
+`notify` needs only `repo` to point at the primary; `workflow`, `token`, `deploy_name`, and `environment` all have defaults and exist to override them when the satellite's local names differ from what the primary expects. Full field detail is in the [manifest reference](/cascade/reference/manifest/).
+
+## Cross-repo artifact tracking
+
+The primary writes every satellite's reported state into its own manifest, under `state..external.`, alongside its local deploy state:
+
+```yaml
+state:
+ dev:
+ sha: abc123
+ deploys:
+ app: { sha: abc123 } # local deploy
+ external:
+ cdk: { repo: org/cdk-infra, sha: cdk123 } # external deploy
+ k8s: { repo: org/k8s-manifests, sha: k8s456 } # external deploy
+```
+
+Because multiple satellites can notify the primary at nearly the same time, `external update` serializes: it commits, and if the push is rejected as non-fast-forward, it fetches the remote tip, resets onto it, and re-applies the mutation before retrying, so one satellite's update never clobbers another's. Once a source is recorded, promoting the primary carries every source, local and external, through the rest of the environment chain together.
+
+A build or deploy callback can also pull a satellite's workflow in synchronously with `uses:` during the primary's own run, instead of waiting on a `notify` round trip. Use whichever fits: `notify` plus `external update` for independently-scheduled satellites, an inline `uses:` call when the primary should drive the satellite's build itself.
+
+## Wayfinding
+
+**Prerequisite**: [Manifest reference](/cascade/reference/manifest/) for the `external` and `notify` field shapes.
+**Next**: [Generated workflows](/cascade/reference/generated-workflows/) for what `external-update.yaml` and the notify dispatch look like on disk.
diff --git a/docs/src/content/docs/guides/promote.md b/docs/src/content/docs/guides/promote.md
new file mode 100644
index 00000000..033de373
--- /dev/null
+++ b/docs/src/content/docs/guides/promote.md
@@ -0,0 +1,72 @@
+---
+title: Promote a release
+description: Trigger a promotion, pick a mode, and know what to watch as it advances an environment.
+---
+
+Promotion moves a build already validated in one environment into the next one, without rebuilding it. It runs as the generated `promote.yaml` workflow; see [Promote workflow anatomy](/cascade/reference/generated-workflows/#promote-workflow-anatomy) for the job-by-job structure.
+
+## Trigger a promotion
+
+Dispatch it from the Actions tab or the CLI:
+
+```bash
+gh workflow run promote.yaml -f mode=default
+```
+
+With no other inputs, `mode=default` advances the chain by exactly one step: the next environment, or the release stage at the top of the chain.
+
+## Promote modes and gates
+
+`mode` is a dropdown generated from your `environments` list, in position order (last = release stage, second-to-last = prerelease stage):
+
+| Mode | Behavior |
+|------|----------|
+| `default` | Advance one logical step from the environment currently ahead. |
+| `-to-` (for example `dev-to-prod`) | Cascade through every environment between `from` and `to`, deploying and finalizing each one in turn. |
+
+A breaking-change gate sits at the prerelease-to-release boundary regardless of mode. If the commits being promoted include a breaking change, the run stops there unless you pass `allow_breaking_changes: true`.
+
+| Input | Type | Default | Purpose |
+|-------|------|---------|---------|
+| `mode` | choice | `default` | See above. |
+| `force` | boolean | `false` | Continue past a failed step (default mode only). |
+| `allow_breaking_changes` | boolean | `false` | Required to cross the prerelease-to-release boundary with a breaking change. |
+| `dry_run` | boolean | `false` | Resolve and print the plan; skip deploys and state writes. |
+| `deploys` | string | `all` | See [Selective deploys](#selective-deploys). |
+| `rollback_on_failure` | boolean | `true` | See below. |
+
+## Atomic promotion and rollback-on-failure
+
+With `rollback_on_failure: true` (the default), a promotion is all-or-nothing. Preflight records the target environment's current SHA as `rollback_sha`; if any deploy job fails, the deploys that already succeeded are rolled back to that SHA. Either every deploy lands or none does. Set it to `false` for a non-atomic promotion that leaves whatever succeeded in place.
+
+This is a different knob from `rollout.fail_fast` and `rollout.max_parallel` on a `deploys[].rollout` entry in the manifest. Those two control the GitHub Actions `strategy:` block on that one deploy's matrix job (whether one failed matrix leg cancels the rest, and how many legs run in parallel); `rollback_on_failure` controls what promote does across deploys after a failure. See [the deploy strategy block](/cascade/reference/generated-workflows/#the-deploy-strategy-block) for the manifest-to-YAML mapping. Everything else under `rollout` (`type`, `canary`, `blue_green`) is reserved and has no effect on generated output.
+
+## Selective deploys
+
+Use `deploys` to limit which deployables run:
+
+```yaml
+deploys: "app,infra" # only these two
+deploys: "all" # default: every configured deploy
+```
+
+Promote also skips a deploy on its own when there is nothing to do: it compares the target's last-deployed SHA per deployable against the source SHA and runs only the ones with real changes in their trigger paths. Selective deploys and this per-deployable change detection combine, so `deploys: "app,infra"` still skips `infra` if nothing under its triggers changed.
+
+## Publish and version determination
+
+When the manifest has a `publish:` callback, crossing the prerelease-to-release boundary adds a publish step once per configured build. See [Publish](/cascade/reference/generated-workflows/#publish) for the exact dispatch payload.
+
+For the release stage, version is the latest semver tag auto-incremented from conventional commits since that tag (major for a breaking change, minor for a feature, patch for a fix), or an explicit `version_override` input when you need to force a specific bump. The rc suffix is dropped at this boundary.
+
+## What to watch
+
+- **Preflight** fails fast on a bad source/target pair, a non-ancestor SHA, or the breaking-change gate; the run log names which one.
+- **Deploy jobs** run as a matrix; check each deployable's own job for its callback's output, not just the aggregate status.
+- **Rollback jobs** only appear when `rollback_on_failure: true` and a deploy actually failed; their presence in the run means something needed reverting.
+- **Finalize** is where state, changelog, and release actually get written. A promotion that finishes deploy but fails finalize has redeployed without updating recorded state; rerun finalize rather than the whole promotion.
+
+## Wayfinding
+
+**Prerequisite:** [Getting started](/cascade/start/getting-started/) for a running pipeline with at least one promotable environment.
+
+**Next:** [Run a hotfix](/cascade/guides/hotfix/) for the case a normal promotion cannot serve: patching one diverged environment without dragging in every intervening trunk commit.
diff --git a/docs/src/content/docs/guides/rollback.md b/docs/src/content/docs/guides/rollback.md
new file mode 100644
index 00000000..e09202a5
--- /dev/null
+++ b/docs/src/content/docs/guides/rollback.md
@@ -0,0 +1,65 @@
+---
+title: Roll back an environment
+description: Re-deploy a prior version or SHA to a promoted environment, and wire an external system to trigger it.
+---
+
+Rollback re-deploys a prior version or SHA to a promoted environment, defaulting to the previous version (N-1). It reuses the same deploy callbacks promote drives; there is no separate rollback deploy path. See [Hotfix and rollback workflows](/cascade/reference/generated-workflows/#hotfix-and-rollback-workflows) for the generated job structure.
+
+## The first-environment guard
+
+Rollback covers promoted environments only. The first environment tracks trunk directly and is never promoted into, so it has no deploy history to roll back to. The workflow's environment dropdown offers only the promoted environments; targeting the first environment fails fast with that guidance. Roll it forward instead, by reverting the offending change on trunk.
+
+## Trigger a rollback
+
+Dispatch `cascade-rollback.yaml`:
+
+```bash
+gh workflow run cascade-rollback.yaml \
+ -f environment=prod \
+ -f target=v1.4.2
+```
+
+| Input | Default | Meaning |
+|-------|---------|---------|
+| `environment` | (required) | Environment to roll back. |
+| `target` | previous version (N-1) | Prior version or SHA; omit to use the previous version. |
+| `deployable` | whole environment | Limit the rollback to one deployable. |
+| `dry_run` | `false` | Resolve and print the plan without deploying. |
+
+A read-only preflight resolves the target, the deploy stage re-runs the configured deploy callbacks keyed on that SHA, and finalize writes the rolled-back state to trunk.
+
+For a state-only correction, where the running system already reflects the older version and only the manifest needs to catch up, `cascade rollback --env prod --to v1.2.2` writes the state directly without invoking any deploy callback. Use the generated workflow for an actual re-deploy.
+
+## The rollback manifest block
+
+The rollback workflow is `workflow_dispatch`-only by default: byte-for-byte unchanged unless you configure it. Set `rollback.repository_dispatch` to let an external system (an alerting or incident pipeline) trigger the same rollback:
+
+```yaml
+rollback:
+ repository_dispatch:
+ types: [rollback-requested]
+```
+
+This adds a `repository_dispatch` trigger alongside the unchanged `workflow_dispatch`; every rollback parameter read then coalesces the manual input with the dispatch payload, so both paths resolve the same target. `repository_dispatch` carries no `inputs`, so the external caller supplies parameters in `client_payload`, keyed name-for-name against the manual inputs above:
+
+```bash
+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'
+```
+
+At least one event type is required, and each may contain only letters, digits, dots, hyphens, and underscores. This is a real, emitted manifest field, not a placeholder; see the [manifest reference](/cascade/reference/manifest/) for the full block and its validation rules.
+
+## What to watch
+
+- **Preflight resolution** shows where the target came from (current state, the deploy-history ring, or manifest git history) so you can confirm it picked the SHA you meant, especially with a short SHA prefix.
+- **A no-op result** means the environment (or deployable) is already at the resolved target; nothing runs.
+- **Finalize marks the environment diverged.** A rolled-back environment behaves like a hotfixed one until the next forward promotion rejoins it to trunk.
+- **`dry_run: true`** on either trigger path resolves and prints the plan without deploying or writing state, useful for confirming what an external dispatch would do before wiring it up live.
+
+## Wayfinding
+
+**Prerequisite:** [Promote a release](/cascade/guides/promote/) for the normal path rollback undoes.
+
+**Next:** [Simulate and verify](/cascade/guides/simulate-and-verify/) to preview a rollback (or any promotion) before it runs for real.
diff --git a/docs/src/content/docs/guides/simulate-and-verify.md b/docs/src/content/docs/guides/simulate-and-verify.md
new file mode 100644
index 00000000..b2874814
--- /dev/null
+++ b/docs/src/content/docs/guides/simulate-and-verify.md
@@ -0,0 +1,134 @@
+---
+title: Simulate and verify
+description: Preview a promotion, release, rollback, or hotfix before it runs, then keep committed workflows honest with verify and plan.
+---
+
+Three commands let you check a change before or after it lands: `simulate` previews what an orchestration action would do against your manifest, `plan` previews what `generate-workflow` would change on disk, and `verify` fails a CI job when committed workflows drift from what the manifest would generate. All three are read-only.
+
+## Simulate a promotion, release, rollback, or hotfix
+
+`cascade simulate ` replays the same orchestration logic the live workflows use, in record-only mode, and prints a before/after state diff plus the ordered sequence of steps the orchestration would take. Nothing runs: no GitHub call, no container, no git command, and the manifest is never modified.
+
+```bash
+cascade simulate promote
+```
+
+```
+Simulating: promote (mode=default)
+State diff:
+ uat:
+ version: (none) -> v1.2.0-rc.1
+ sha: (none) -> a1b2c3d4e5f6
+Effects (in order):
+ 1. [run] deploy uat (from dev (sha a1b2c3d, version v1.2.0-rc.1))
+ 2. [run] write state uat (sha a1b2c3d, version v1.2.0-rc.1)
+ 3. [run] release prerelease v1.2.0 (rc v1.2.0-rc.1, sha a1b2c3d)
+ 4. [skip] promote prod (no change required)
+
+Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts.
+```
+
+The other three actions follow the same shape:
+
+```bash
+cascade simulate release # preview the prerelease/release boundary
+cascade simulate rollback --env prod # preview reverting an environment
+cascade simulate hotfix --env uat --fix [,...] # preview applying trunk commits as a hotfix
+```
+
+Each step in the effect list carries a disposition: `run` (the orchestration would carry it out), `skip` (a no-op), or `gate` (held back behind a guard, such as a finalize blocked by a failed deploy). `rollback` resolves its target from the in-state deploy-history ring, never from git, so it needs `--env` and optionally `--to`. `hotfix` needs `--env` and `--fix`; with multiple fix commits, add `--merge-sha` to name the resolution-branch tip. Full flag lists for every action live in the [CLI reference](/cascade/reference/cli/).
+
+## Deploy stubs and outcome injection
+
+Real build and deploy callbacks never run in a simulation. Each one is recorded as a stubbed step instead, and every stub resolves to `success` by default, so the orchestration sequences as if all callbacks had passed.
+
+To preview gating, inject a per-callback outcome with `--deploy-result name=outcome` (`success`, `failure`, or `skipped`; repeatable):
+
+```bash
+cascade simulate promote --deploy-result services=failure
+```
+
+```
+Effects (in order):
+ 1. [run] deploy uat (from dev (sha a1b2c3d, version v1.2.0-rc.1))
+ 2. [run] deploy services (simulated failure (not executed))
+ 3. [gate] write state uat (deploy "services" simulated failure; trunk state left unchanged)
+ 4. [run] release prerelease v1.2.0 (rc v1.2.0-rc.1, sha a1b2c3d)
+ 5. [skip] promote prod (no change required)
+```
+
+A `skipped` outcome is never a failure, but it does not count as a success either. When every configured deploy is skipped, nothing was deployed, so the finalize still gates.
+
+## `--mode default | cascade`
+
+`promote` is the one action with a mode switch. `default` advances one environment; `cascade` carries state through every intermediate hop to a `--target`:
+
+```bash
+cascade simulate promote --mode cascade --target dev-to-prod
+```
+
+## JSON output
+
+Pass `--json` on any action for a machine-readable result: the action, its description, the full diff, and the effect list, suitable for piping into `jq` or asserting on in a script.
+
+```bash
+cascade simulate promote --json
+```
+
+```json
+{
+ "action": "promote",
+ "describe": "promote (mode=default)",
+ "diff": {
+ "envs": [
+ {
+ "environment": "uat",
+ "version": { "field": "version", "from": "(none)", "to": "v1.2.0-rc.1", "changed": true }
+ }
+ ]
+ }
+}
+```
+
+## `verify` for drift
+
+Where `simulate` previews an action, `verify` checks state: it compares the committed workflow and action files against what the manifest would generate right now, and reports drift without writing anything.
+
+```bash
+cascade verify
+```
+
+`verify` covers the complete generated file set (orchestrate, promote or release, external-update, validate-check, merge-queue, hotfix, rollback, pr-preview, drift-check, drift-comment, and the manage-release composite action) and also reports orphans: cascade-owned files left behind after a manifest change removes an environment or build. Pass `--allow-orphans` when stale generated files are expected.
+
+`verify` owns a precise three-way exit contract, mirroring `diff(1)`:
+
+| Exit | Meaning |
+|------|---------|
+| `0` | No drift. Every generated file is present and byte-identical, and no orphaned generated files remain. |
+| `1` | Drift detected. A generated file is missing, its committed bytes differ, or a cascade-owned file is orphaned (unless `--allow-orphans`). |
+| `2` | Operational failure. The manifest is missing or invalid, or another failure prevented the check from running at all. |
+
+Wire it into CI as a single step, or set `drift_check.enabled: true` in the manifest and let `generate-workflow` emit the drift-check workflow for you:
+
+```yaml
+- run: cascade verify
+```
+
+## `plan`
+
+`plan` is the human-facing preview counterpart to `verify`: a per-file unified diff of what `generate-workflow` would change, without writing anything.
+
+```bash
+cascade plan
+```
+
+A new file appears as a whole-file add, a changed file as a unified hunk, and a file already in sync produces no diff. When nothing is pending, `plan` prints a single `plan: N files, no pending changes` line. Read `plan` at the terminal to see what would change; wire `verify` into CI to enforce that nothing does. `plan` always exits `0` on success, whether or not it printed a diff; a non-zero exit means the preview itself could not run.
+
+## Scope note: simulation fidelity is bounded
+
+`cascade simulate` validates cascade's orchestration, the state transitions and run/skip/gate decisions, not your build and deploy scripts. A green simulation means the orchestration would sequence correctly given the inputs you supplied; it is not a test that your deploy actually works. To exercise your real scripts, use the live pipeline. A higher-fidelity local simulator that runs the generated workflows end to end with [act](https://github.com/nektos/act) and a local [Gitea](https://about.gitea.com/) server is planned but not yet shipped; track it in the [issue tracker](https://github.com/stablekernel/cascade/issues).
+
+## Wayfinding
+
+**Prerequisite**: [Getting started](/cascade/start/getting-started/) to have a manifest and generated workflows in place.
+**Next**: [Manifest reference](/cascade/reference/manifest/) for every field these commands read.
diff --git a/docs/src/content/docs/guides/visualize.md b/docs/src/content/docs/guides/visualize.md
new file mode 100644
index 00000000..2cec8d2c
--- /dev/null
+++ b/docs/src/content/docs/guides/visualize.md
@@ -0,0 +1,93 @@
+---
+title: Visualize the pipeline
+description: Render the manifest's generated pipeline as a Mermaid diagram with cascade graph.
+---
+
+`cascade graph` turns your manifest into a Mermaid diagram on stdout. GitHub renders Mermaid natively in Markdown, so the output pastes directly into a README or pull request. `graph` is read-only: it never writes files, runs git, or modifies the repository.
+
+## Render a diagram
+
+```bash
+cascade graph
+```
+
+With no flags, `graph` auto-detects `.github/manifest.yaml`, renders the `jobs` granularity, and prints Mermaid to stdout. Redirect it to a file or pipe it wherever you need the diagram:
+
+```bash
+cascade graph > pipeline.mmd
+```
+
+## Granularities
+
+`--granularity` chooses the projection. Each one answers a different question about the same pipeline:
+
+| Granularity | Default? | Shows |
+|-------------|----------|-------|
+| `jobs` | yes | The full job dependency graph. Hard dependencies render as solid arrows, optional ordering-only ones (`optional_depends_on`) as dotted arrows. |
+| `stages` | | The coarse lifecycle flow: trunk through build, deploy, and promote to release. |
+| `env` | | The promotion state machine, including any hotfix divergence and rejoin. |
+| `cross-repo` | | The multi-repo flow: a lane per repository, with the primary coordinating its satellites and any satellite-to-primary notify edge. |
+
+```bash
+cascade graph --granularity env
+cascade graph --granularity stages > pipeline.mmd
+cascade graph --granularity cross-repo
+```
+
+Reach for `stages` or `env` when explaining the pipeline to someone new, `jobs` when debugging a dependency ordering question, and `cross-repo` on a primary repo coordinating satellites (see [Coordinate multiple repos](/cascade/guides/multi-repo/)).
+
+## Themes
+
+`--theme` selects the diagram's palette. Two built-ins ship, plus a custom path:
+
+| Value | Description |
+|-------|--------------|
+| `cascade` (default) | The branded palette: a distinct accent color per node kind over a mid-gray edge color. |
+| `bland` | A monotone grayscale palette for low-distraction or print contexts. |
+| a path to a JSON file | A custom theme, in the same shape as the built-ins. |
+
+```bash
+cascade graph --theme bland
+cascade graph --theme ./my-theme.json
+```
+
+A custom theme file sets a name, an optional Mermaid base theme and line color, and a fill/stroke/text style per node kind:
+
+```json
+{
+ "name": "mono-blue",
+ "base": "base",
+ "lineColor": "#57606a",
+ "nodeStyles": {
+ "build": { "fill": "#1f6feb", "stroke": "#0b3d91", "text": "#ffffff" },
+ "deploy": { "fill": "#1f6feb", "stroke": "#0b3d91", "text": "#ffffff" }
+ }
+}
+```
+
+`--format` accepts only `mermaid` today; it exists as a stable flag for a future renderer.
+
+## Embed in a README marker block
+
+`graph` always prints to stdout, so keeping an embedded diagram current is a paste, not an automated sync. Bound the block with HTML comments so it is easy to find and replace later:
+
+```markdown
+
+```mermaid
+flowchart TD
+ ...
+```
+
+```
+
+Regenerate it whenever the pipeline shape changes:
+
+```bash
+cascade graph --granularity stages
+# paste the output between the markers
+```
+
+## Wayfinding
+
+**Prerequisite**: [Getting started](/cascade/start/getting-started/) for a manifest to render.
+**Next**: none. This guide stands alone; use it alongside whichever other task guide brought you here.
diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx
index c8255c22..83ccde23 100644
--- a/docs/src/content/docs/index.mdx
+++ b/docs/src/content/docs/index.mdx
@@ -9,7 +9,7 @@ hero:
file: ../../assets/hero.png
actions:
- text: Get started
- link: /cascade/getting-started/
+ link: /cascade/start/getting-started/
icon: right-arrow
variant: primary
- text: View on GitHub
@@ -25,42 +25,43 @@ Define what to build and where to deploy in one manifest. Cascade generates the
The manifest (`.github/manifest.yaml`) is the single source of truth. It holds the pipeline configuration and the live deployment state for every environment. You run `cascade generate-workflow` once. After that, the generated workflows own their execution.
-
+
The mental model: trunk, the environment chain, the release boundary, and
the rollback and hotfix off-ramps.
- [See the stage graph →](/cascade/stage-graph/)
+ [See how it works →](/cascade/start/how-it-works/)
Walk through a first manifest and the generated workflows.
- [Get started →](/cascade/getting-started/)
+ [Get started →](/cascade/start/getting-started/)
Every field of the manifest documented in one place.
- [Read the reference →](/cascade/configuration/)
+ [Read the reference →](/cascade/reference/manifest/)
The inputs and outputs your build, deploy, and publish workflows exchange
with cascade.
- [See the contract →](/cascade/callback-contract/)
+ [See the contract →](/cascade/reference/callbacks/)
-
- How orchestrate, promote, and release move artifacts through environments.
+
+ The exact file set cascade emits, and how orchestrate, promote, hotfix,
+ and rollback move artifacts through environments.
- [Explore workflows →](/cascade/workflows/)
+ [Explore generated workflows →](/cascade/reference/generated-workflows/)
The commands and flags cascade exposes.
- [Browse the CLI →](/cascade/cli-reference/)
+ [Browse the CLI →](/cascade/reference/cli/)
The design, and what cascade does and does not own.
- [Understand the design →](/cascade/architecture/)
+ [Understand the design →](/cascade/internals/architecture/)
diff --git a/docs/src/content/docs/internals/architecture.md b/docs/src/content/docs/internals/architecture.md
new file mode 100644
index 00000000..56d145d0
--- /dev/null
+++ b/docs/src/content/docs/internals/architecture.md
@@ -0,0 +1,119 @@
+---
+title: Architecture
+description: How cascade is put together: the design principles, the shape of the system, and the seams built for extension.
+---
+
+This page is for contributors and evaluators who want the internals: why cascade
+is built the way it is, how its packages divide responsibility, and where the
+design leaves room to grow. If you want to operate a pipeline rather than extend
+the tool, start with [how cascade works](/cascade/start/how-it-works/) instead.
+
+## Design principles
+
+1. **Build once, promote everywhere.** One artifact moves through every environment; nothing rebuilds on the way.
+2. **Change-driven.** Cascade builds and deploys only what a change actually touches.
+3. **Trunk-based.** A single `main` branch backs short-lived feature branches.
+4. **Callback contract.** Cascade compiles and orchestrates the pipeline; adopting repositories own build and deploy logic.
+5. **State tracking.** The manifest records what is deployed where, so every decision reads from one source of truth.
+
+## System overview
+
+Cascade ships three surfaces from one codebase: a CLI binary, a set of reusable GitHub Actions workflows, and a handful of composite actions. The CLI is both the tool you run locally and the engine the generated workflows call at each step.
+
+```mermaid
+flowchart TD
+ subgraph cascade["cascade"]
+ direction TB
+ subgraph surfaces[" "]
+ direction LR
+ cli["CLI tool (cascade)"]
+ wf["Workflows (reusable)"]
+ act["Actions (composite)"]
+ end
+ subgraph pkgs["Go packages, grouped by role"]
+ direction LR
+ config["config & schema"]
+ gen["generation"]
+ lifecycle["lifecycle commands"]
+ gitstate["git & state"]
+ end
+ surfaces --> pkgs
+ end
+
+ cascade --> repos["Adopting repos (callbacks)"]
+ cascade --> api["GitHub API (releases, deployments)"]
+```
+
+A manifest describes the pipeline; `generate-workflow` compiles it into the reusable workflows and composite actions listed in [Generated workflows](/cascade/reference/generated-workflows/). Everything downstream, from a trunk merge to a hotfix, runs through the workflows that compile step calls back into the same CLI.
+
+## Code map
+
+The table groups the current package tree by the role it plays, not by listing every package. It is intentionally non-exhaustive: browse `internal/` in the source tree, or run `go doc`, for the full and current list.
+
+| Area | Representative packages | What it owns |
+|---|---|---|
+| Config and schema | `config`, `schema`, `environments`, `branchprotection` | Parsing and validating the manifest, schema versioning, and emitting operator-appliable environment and branch-protection config |
+| Workflow generation | `generate`, `graph`, `visualize` | Compiling a manifest into GitHub Actions YAML, and rendering Mermaid diagrams of the result |
+| Lifecycle commands | `orchestrate`, `promote`, `hotfix`, `rollback`, `release`, `version`, `simulate`, `plan`, `verify`, `status` | The logic behind each pipeline stage: building the execution plan, promoting between environments, hotfixing, rolling back, computing versions, and checking for drift |
+| Change detection and history | `changes`, `changelog` | Mapping changed files to triggered builds and deploys, and assembling changelogs from conventional commits |
+| Git, state, and reconcile | `git`, `statewrite`, `pinreconcile`, `fleetreconcile` | Git operations, the manifest state write-and-retry path, and reconciling drifted action pins |
+| Cross-repo and platform | `external`, `ghaoutput`, `github` | Satellite-to-primary notification, GitHub Actions output plumbing, and the GitHub API client |
+| Bootstrap and utilities | `initcmd`, `scaffold`, `reset` | Scaffolding a new manifest and topology, and the test-only reset command |
+
+For the anatomy of what each generated workflow actually contains, see [Generated workflows](/cascade/reference/generated-workflows/); for how to operate a promotion, hotfix, or rollback, see the matching [guides](/cascade/guides/promote/).
+
+## State machine
+
+Every environment's manifest state advances through the same shape: empty, then deployed, then (for the release-bearing environment) published.
+
+```mermaid
+stateDiagram-v2
+ [*] --> Empty
+ Empty --> Deployed: merge to trunk or promote
+ Deployed --> Diverged: hotfix or rollback
+ Diverged --> Deployed: rejoin or re-promote
+ note right of Deployed
+ release moves draft to prerelease to published
+ as it reaches later environments
+ end note
+```
+
+For each environment, the manifest tracks the deployed commit, when and by whom, the version once one applies, and a per-deployable SHA for every build and deploy so promotions can act on each artifact independently rather than the environment as a whole.
+
+## Change detection
+
+A build or deploy is triggered by comparing the changed files between two commits against its configured trigger patterns:
+
+1. Get the changed files between the base and head commit.
+2. For each build, mark it triggered if any changed file matches one of its trigger patterns.
+3. For each deploy: if it depends on a build, it is triggered when that build is triggered; otherwise it is triggered by its own patterns, or unconditionally if it has none.
+4. Builds and deploys are then ordered by `depends_on` through a topological sort, so a dependent never starts before its prerequisite.
+
+Promotions apply the same idea per deployable, not per environment: a promotion compares the target's recorded SHA for one deployable against the source's, and only runs the deploys where that comparison shows a real change. A deployable already at the source's SHA is skipped; only ones that lag are promoted.
+
+## Multi-repo model
+
+For pipelines that span more than one repository, one repository is the primary: it owns the environment state machine and coordinates every promotion. Other repositories are satellites that build their own artifact and notify the primary after each deploy, so the primary's manifest becomes the single record of what is deployed where, across every repository.
+
+See [Coordinate multiple repos](/cascade/guides/multi-repo/) for how to configure `external` and `notify` and for the operational detail this page does not repeat.
+
+## Security model
+
+Generated workflows run in the adopting repository's own context: secrets are passed with `secrets: inherit` or scoped per callback, environment protection comes from GitHub environments, and cross-repo dispatch requires an explicit token. Cascade's own repository stores no adopter secrets.
+
+See [Security](/cascade/security/) for the full trust model, action-pinning policy, and hardening checklist.
+
+## Extension points
+
+- **Custom changelog**: override with `changelog.workflow`.
+- **Custom release**: override with `release.tag` to hand releases to an external tool.
+- **Custom inputs**: pass arbitrary values into a callback via `inputs` and `env_inputs`.
+- **Output chaining**: a callback's outputs are auto-discovered and passed to whatever depends on it.
+- **GitHub Environments**: `environment_config` lets a manifest express required reviewers, wait timers, and branch policy per environment; `cascade environments` emits that as a file for an operator to apply. Cascade never calls the Environments REST API itself, so applying the config stays a deliberate operator step. See [the manifest reference](/cascade/reference/manifest/) for the field shape.
+
+New fields under `environment_config` and similar blocks are additive by design: a manifest that omits them is valid and behaves exactly as it does today, so this extension point can grow without a schema version bump.
+
+## Wayfinding
+
+**Prerequisite**: [How Cascade works](/cascade/start/how-it-works/) for the mental model this page assumes.
+**Next**: [How Cascade is tested](/cascade/internals/testing/) for how each of these pieces is verified.
diff --git a/docs/src/content/docs/coverage-matrix.md b/docs/src/content/docs/internals/coverage-matrix.md
similarity index 97%
rename from docs/src/content/docs/coverage-matrix.md
rename to docs/src/content/docs/internals/coverage-matrix.md
index 7ad0d56f..32255df8 100644
--- a/docs/src/content/docs/coverage-matrix.md
+++ b/docs/src/content/docs/internals/coverage-matrix.md
@@ -3,7 +3,7 @@ title: Feature coverage matrix
description: A feature-by-feature map of how cascade is verified. Every capability is traced to its hermetic act plus gitea scenario, its live-fleet probe, and its unit coverage, with one line on what each layer proves and why both layers exist.
---
-The [How cascade is tested](/cascade/testing/) page explains the two validation
+The [How Cascade is tested](/cascade/internals/testing/) page explains the two validation
layers and the reconcile gate that makes the live layer trustworthy. This page is
the detailed companion: a feature-by-feature table that traces each cascade
capability to the exact place it is exercised.
@@ -106,6 +106,8 @@ only under real installation tokens on the fleet, never in the token-free harnes
| Callback retry wrapper | | retry-wrapper jobs present (callbacks); retry shim jobs (3env gen-time) | `internal/generate` | The retry jobs are emitted and wired for `retries: N` |
| Signed auto-commit identity (`auto_commits`) | `03-three-env-repo` | auto_commits author and message (3env) | `internal/promote/auto_commit_sha*.go` | The state commit carries the configured author and message |
+See [the `auto_commits` field](/cascade/reference/manifest/) in the manifest reference for what a callback must do to trigger this capture.
+
## Manifest schema and emitted shape
These are about what the generator emits. Several are asserted at the emission
@@ -190,3 +192,8 @@ across the layers, with a documented ceiling. Every lifecycle and guard behavior
that can run live runs live in the fleet; every emission and synthesized-condition
behavior is asserted in the act plus gitea harness; every piece of pure logic is
covered by unit tests; and the platform ceiling is validated by design.
+
+## Wayfinding
+
+**Prerequisite**: [How Cascade is tested](/cascade/internals/testing/) for the two layers this matrix traces against.
+**Next**: [Release orchestration](/cascade/internals/release-orchestration/) for how the fleet fits into cascade's own release chain.
diff --git a/docs/src/content/docs/release-orchestration.md b/docs/src/content/docs/internals/release-orchestration.md
similarity index 98%
rename from docs/src/content/docs/release-orchestration.md
rename to docs/src/content/docs/internals/release-orchestration.md
index e3ea6a05..2d4eb900 100644
--- a/docs/src/content/docs/release-orchestration.md
+++ b/docs/src/content/docs/internals/release-orchestration.md
@@ -204,3 +204,8 @@ A `force` plus `dry_run` dispatch therefore exercises every component of the rea
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
+
+**Prerequisite**: [Architecture](/cascade/internals/architecture/) for the system this release chain ships.
+**Next**: none. This is the end of the internals track.
diff --git a/docs/src/content/docs/testing.md b/docs/src/content/docs/internals/testing.md
similarity index 95%
rename from docs/src/content/docs/testing.md
rename to docs/src/content/docs/internals/testing.md
index 1e02ae6e..ba023c4a 100644
--- a/docs/src/content/docs/testing.md
+++ b/docs/src/content/docs/internals/testing.md
@@ -1,5 +1,5 @@
---
-title: How cascade is tested
+title: How Cascade is tested
description: How cascade verifies its generated pipelines across two complementary layers. A hermetic act plus gitea harness runs on every pull request, and a live fleet exercises real GitHub Actions across every supported topology. The reconcile gate makes fleet results trustworthy, and the platform ceiling is validated by design rather than execution.
---
@@ -30,7 +30,7 @@ The fleet (`.github/workflows/fleet-e2e.yaml`) fans out to a set of purpose-buil
The fleet proves the things that only real GitHub can prove: a real release object transitioning from draft to prerelease to published, real release-candidate tags being reaped on publish, the Contents API state-write path, cross-repo dispatch between real repositories, and real branch protection being written through a scoped token.
-The fleet fans out in sequenced lanes so peak live concurrency on its one shared token stays low, accepts a `repos` selector for running a single lane during development, and is cut and promoted under a nightly gate that releases only on a fully green fleet. The [Release orchestration](/cascade/release-orchestration/) page documents that machinery in full.
+The fleet fans out in sequenced lanes so peak live concurrency on its one shared token stays low, accepts a `repos` selector for running a single lane during development, and is cut and promoted under a nightly gate that releases only on a fully green fleet. The [Release orchestration](/cascade/internals/release-orchestration/) page documents that machinery in full.
### Unit tests (pure logic)
@@ -82,3 +82,8 @@ Some behavior depends on GitHub platform features and real cloud outcomes that n
- **Real cloud deploy outcomes.** Cascade orchestrates your build and deploy callbacks; what those callbacks do against your cloud is yours to test.
For each of these, cascade asserts the part it owns: the generated workflow structure and the orchestration around the call. It treats the platform-enforced outcome as a contract validated by review, because executing it would require faking the platform rather than testing cascade.
+
+## Wayfinding
+
+**Prerequisite**: [Architecture](/cascade/internals/architecture/) for the system this testing strategy validates.
+**Next**: [Feature coverage matrix](/cascade/internals/coverage-matrix/) for the feature-by-feature trace.
diff --git a/docs/src/content/docs/callback-contract.md b/docs/src/content/docs/reference/callbacks.md
similarity index 76%
rename from docs/src/content/docs/callback-contract.md
rename to docs/src/content/docs/reference/callbacks.md
index 17dd8ad4..4f53013d 100644
--- a/docs/src/content/docs/callback-contract.md
+++ b/docs/src/content/docs/reference/callbacks.md
@@ -1,24 +1,23 @@
---
-title: Callback Contract
-description: Defines the inputs, outputs, and structural requirements for validate, build, deploy, and publish callback workflows invoked by the cascade framework.
+title: Callback contract
+description: The inputs, outputs, and structural requirements for the validate, build, deploy, and publish callback workflows cascade invokes.
---
-The framework calls your workflows (callbacks) during CI/CD execution. This document defines the contract your workflows must follow.
+Cascade calls your workflows (callbacks) during pipeline execution. This page defines the contract those workflows must follow: what inputs they receive, what outputs they must declare, and how state flows between them.
-Every callback (validate, build, deploy, publish) is a reusable workflow that you declare with `workflow:` in the manifest. The framework invokes it with `workflow_call`.
+## Callback types
-## Migrating from inline `run:`/`shell:` callbacks
-
-Inline `run:`/`shell:` callbacks were removed. A callback can no longer carry a `run:` script or a `shell:` setting in the manifest; it must point at a reusable workflow via `workflow:`. The manifest still parses these keys, but validation now rejects them.
-
-To migrate, move the script into a reusable workflow under `.github/workflows/`, expose it with `on: workflow_call` (declaring the standard `environment`, `sha`, and `dry_run` inputs), and replace the callback's `run:`/`shell:` with `workflow: .github/workflows/.yaml`. The script text becomes a `run:` step inside that workflow's job. The sections below show the required structure for each callback type.
-
-## Overview
+| Type | Purpose | Standard inputs |
+|------|---------|-----------------|
+| **Validate** | Pre-build checks (lint, test) | `environment`, `sha`, `dry_run` |
+| **Build** | Produce artifacts | `environment`, `sha`, `dry_run` |
+| **Deploy** | Apply changes to an environment | `environment`, `sha`, `dry_run`, plus build outputs |
+| **Publish** | Retag artifacts at the prerelease-to-release boundary | `build_name`, `old_version`, `new_version`, `sha`, `artifact_id` |
-Adopting repositories provide callback workflows that the framework invokes:
+Every callback is a reusable workflow you declare with `workflow:` in the manifest. Cascade invokes it with `workflow_call`.
```
-Framework Your Repository
+Cascade Your Repository
┌─────────────────┐ ┌──────────────────┐
│ orchestrate.yaml│──workflow_call──▶│ validate.yaml │
│ │──workflow_call──▶│ build-app.yaml │
@@ -29,37 +28,29 @@ Framework Your Repository
└─────────────────┘ └──────────────────┘
```
-## Callback Types
-
-| Type | Purpose | Standard Inputs |
-|------|---------|-----------------|
-| **Validate** | Pre-build checks (lint, test) | environment, sha, dry_run |
-| **Build** | Produce artifacts | environment, sha, dry_run |
-| **Deploy** | Apply changes to an environment | environment, sha, dry_run, plus build outputs |
-| **Publish** | Retag artifacts at the prerelease->release boundary | build_name, old_version, new_version, sha, artifact_id |
+## Standard inputs
-## Standard Inputs
-
-The framework always passes these to validate/build/deploy callbacks:
+Cascade always passes these to validate, build, and deploy callbacks:
| Input | Type | Description |
|-------|------|-------------|
-| `environment` | string | Target environment (e.g., `dev`, `test`, `prod`) |
+| `environment` | string | Target environment (for example `dev`, `test`, `prod`) |
| `sha` | string | Commit SHA being processed |
| `dry_run` | boolean | If `true`, skip mutating operations |
-Any other inputs your callback needs must come from one of:
-1. **Static `inputs:`** in the manifest
-2. **Per-environment `env_inputs:`** in the manifest
-3. **Outputs declared by a `depends_on:` callback** (auto-discovered)
+Any other input your callback needs must come from one of:
+
+1. **Static `inputs:`** in the manifest.
+2. **Per-environment `env_inputs:`** in the manifest.
+3. **Outputs declared by a `depends_on:` callback** (auto-discovered).
-The framework parses your workflow files to discover declared `outputs:` and forwards them to dependents as inputs by name.
+Cascade parses your workflow files to discover declared `outputs:` and forwards them to dependents as inputs by name.
-## Build Workflow Contract
+## Build workflow contract
Build workflows produce artifacts (Docker images, binaries).
-### Required Structure
+### Required structure
```yaml
name: Build App
@@ -161,11 +152,11 @@ jobs:
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
```
-## Deploy Workflow Contract
+## Deploy workflow contract
Deploy workflows apply changes to an environment.
-### Required Structure
+### Required structure
```yaml
name: Deploy Services
@@ -188,7 +179,7 @@ on:
# Add custom outputs as needed
```
-### Receiving Build Outputs
+### Receiving build outputs
When a deploy declares `depends_on: [app]` and the `app` build declares an `image_tag` output, the deploy callback receives `image_tag` as an input automatically:
@@ -204,7 +195,7 @@ on:
required: true
image_tag:
type: string
- required: true # Provided by the framework via output chaining
+ required: true # Provided by cascade via output chaining
```
### Example
@@ -258,11 +249,11 @@ jobs:
--task-definition my-task:${{ inputs.image_tag }}
```
-## Validate Workflow Contract
+## Validate workflow contract
Optional pre-build validation.
-### Required Structure
+### Required structure
```yaml
name: Validate
@@ -325,11 +316,11 @@ jobs:
run: go test -v ./...
```
-## Publish Workflow Contract
+## Publish workflow contract
-The publish callback runs once per build at the prerelease->release boundary (when a draft RC is published as a final semver release). Use it to retag artifacts that still carry their RC version.
+The publish callback runs once per build at the prerelease-to-release boundary (when a draft RC is published as a final semver release). Use it to retag artifacts that still carry their RC version.
-### Required Structure
+### Required structure
```yaml
name: Publish
@@ -359,8 +350,8 @@ on:
| Input | Description |
|-------|-------------|
| `build_name` | Which build's artifacts to retag (matches a `builds[].name`) |
-| `old_version` | RC version currently in the registry (e.g., `v1.0.0-rc.2`) |
-| `new_version` | Final semver to apply (e.g., `v1.0.0`) |
+| `old_version` | RC version currently in the registry (for example `v1.0.0-rc.2`) |
+| `new_version` | Final semver to apply (for example `v1.0.0`) |
| `sha` | Git commit SHA |
| `artifact_id` | Immutable digest from the build's `artifact_id` output (empty if not declared) |
@@ -401,9 +392,9 @@ jobs:
docker push myrepo/${{ inputs.build_name }}:${{ inputs.new_version }}
```
-The framework only carries metadata. The publish callback performs the registry operation. When `artifact_id` is present, use it instead of `old_version` so the target is unambiguous.
+Cascade only carries metadata. The publish callback performs the registry operation. When `artifact_id` is present, use it instead of `old_version` so the target is unambiguous.
-## Custom Inputs
+## Custom inputs
Pass custom inputs via `inputs` and `env_inputs` in the manifest:
@@ -435,7 +426,7 @@ on:
type: boolean
```
-## Output Chaining
+## Output chaining
Outputs from one callback are passed to dependents:
@@ -454,11 +445,11 @@ ci:
# Receives: artifact_id, image_tag as inputs
```
-The framework parses workflow files for `outputs:` and forwards them automatically.
+Cascade parses workflow files for `outputs:` and forwards them automatically.
-## State Capture
+## State capture
-The framework automatically captures into per-environment state:
+Cascade automatically captures into per-environment state:
| Field | Source | Where |
|-------|--------|-------|
@@ -477,11 +468,13 @@ ci:
artifact_id: "sha256:def456..."
```
-## Environment Protection
+Which builds and deploys get captured, and under what tags, is controlled by the `state_tags` field on the corresponding manifest entry. See the `state_tags` field in the [manifest reference](/cascade/reference/manifest/) for the full field definition.
+
+## Environment protection
Use GitHub Environment protection for approval gates. Because every deploy is a reusable workflow, declare the `environment:` key on the job **inside your reusable workflow**. GitHub Actions only allows a job-level `environment:` key on a steps job, never on a job that calls a reusable workflow with `uses:`, so the caller job cascade generates cannot carry it.
-cascade passes the target environment name to your workflow as the `environment` input, so wire it through:
+Cascade passes the target environment name to your workflow as the `environment` input, so wire it through:
```yaml
# your reusable deploy workflow
@@ -493,11 +486,11 @@ jobs:
- run: ./deploy.sh
```
-cascade cannot set `environment:` on the caller job it generates: GitHub Actions rejects a workflow that puts `environment:` on a `uses:` job. cascade therefore emits only the `with: environment:` input on the caller and relies on your reusable workflow to apply the protection rules. cascade prints a generate-time note when `gha_environment` is configured for an environment, reminding you to declare `environment:` inside the reusable workflow.
+Cascade cannot set `environment:` on the caller job it generates: GitHub Actions rejects a workflow that puts `environment:` on a `uses:` job. Cascade therefore emits only the `with: environment:` input on the caller and relies on your reusable workflow to apply the protection rules. Cascade prints a generate-time note when `gha_environment` is configured for an environment, reminding you to declare `environment:` inside the reusable workflow.
Configure protection in GitHub: **Settings -> Environments -> Add required reviewers**.
-## Dry Run Handling
+## Dry run handling
All callbacks should respect `dry_run`:
@@ -513,7 +506,7 @@ All callbacks should respect `dry_run`:
echo "Would deploy ${{ inputs.image_tag }}"
```
-## Error Handling
+## Error handling
Callback failures are handled by the `on_failure` policy:
@@ -524,14 +517,20 @@ Callback failures are handled by the `on_failure` policy:
With `retries: N`, failed callbacks retry up to N times before final failure.
+## Migrating from inline `run:`/`shell:` callbacks
+
+Inline `run:`/`shell:` callbacks were removed. A callback can no longer carry a `run:` script or a `shell:` setting in the manifest; it must point at a reusable workflow via `workflow:`. The manifest still parses these keys, but validation now rejects them.
+
+To migrate, move the script into a reusable workflow under `.github/workflows/`, expose it with `on: workflow_call` (declaring the standard `environment`, `sha`, and `dry_run` inputs), and replace the callback's `run:`/`shell:` with `workflow: .github/workflows/.yaml`. The script text becomes a `run:` step inside that workflow's job. The sections above show the required structure for each callback type.
+
## Tips
### Keep callbacks focused
-- Build -> produce artifacts
-- Deploy -> apply to environment
-- Validate -> check quality
-- Publish -> retag
+- Build produces artifacts.
+- Deploy applies them to an environment.
+- Validate checks quality.
+- Publish retags.
### Consistent naming
@@ -544,7 +543,7 @@ publish.yaml
### Declare outputs explicitly
-The framework discovers outputs by parsing your workflow files. Declare them under `on.workflow_call.outputs`:
+Cascade discovers outputs by parsing your workflow files. Declare them under `on.workflow_call.outputs`:
```yaml
outputs:
@@ -562,3 +561,9 @@ act workflow_call -j build \
--input sha=$(git rev-parse HEAD) \
--input dry_run=true
```
+
+---
+
+**Prerequisite**: [Getting started](/cascade/start/getting-started/) walks through writing your first callbacks and generating a pipeline.
+
+**Next**: the [manifest reference](/cascade/reference/manifest/) documents every field that configures how callbacks are wired together.
diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/reference/cli.md
similarity index 55%
rename from docs/src/content/docs/cli-reference.md
rename to docs/src/content/docs/reference/cli.md
index ba519a93..ba4223aa 100644
--- a/docs/src/content/docs/cli-reference.md
+++ b/docs/src/content/docs/reference/cli.md
@@ -1,40 +1,27 @@
---
-title: CLI Reference
-description: Complete reference for every cascade command, flag, subcommand, environment variable, and exit code.
+title: CLI reference
+description: Every cascade command, flag, subcommand, environment variable, and exit code, verified against the shipped binary.
---
-Complete reference for the `cascade` command-line tool.
+This page is the complete reference for the `cascade` command-line tool: its global
+flags, every command and subcommand, the environment variables it reads, its exit codes,
+and its JSON output. It serves operators wiring pipelines and contributors reading
+generated workflows. Reach for a command by name; the how-to guides link here for flag
+detail.
-## Installation
+## Install
+
+Install is documented in one place. See [Getting started](/cascade/start/getting-started/)
+for the canonical install section, including the pinned `go install` form and the
+`setup-cli` action used inside generated workflows. The short version:
```bash
-# Latest stable
go install github.com/stablekernel/cascade/cmd/cascade@latest
-
-# Latest build from master
-go install github.com/stablekernel/cascade/cmd/cascade@master
-
-# Specific version
-go install github.com/stablekernel/cascade/cmd/cascade@v2.0.4
-
-# Run without installing
-go run github.com/stablekernel/cascade/cmd/cascade@latest version
-```
-
-In GitHub Actions, the generated workflows install the CLI via `setup-cli`. To invoke it manually:
-
-```yaml
-- uses: stablekernel/cascade/.github/actions/setup-cli@master
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- # version: latest # 'beta', or a specific version
```
-The action downloads the GoReleaser archive (`tar.gz`), extracts it, and installs the `cascade` binary on `PATH`.
+## Global flags
-## Global Flags
-
-These flags are available on all commands:
+These flags are available on every command.
| Flag | Type | Description |
|------|------|-------------|
@@ -42,127 +29,46 @@ These flags are available on all commands:
| `--trace` | bool | Enable TRACE-level logging for detailed internals |
| `--json` | bool | Output structured JSON for workflow consumption |
-## Commands
-
-### version
-
-Display version information.
-
-```bash
-cascade version
-```
-
-Output (with `--json`):
-```json
-{
- "version": "v2.0.4",
- "commit": "abc123d",
- "date": "2026-01-15T10:30:00Z"
-}
-```
-
-### parse-config
-
-Parse and validate the manifest file.
-
-```bash
-cascade parse-config
-```
-
-#### Flags
-
-| Flag | Type | Default | Description |
-|------|------|---------|-------------|
-| `--config` | string | auto-detect | Path to manifest file (auto-detects `.github/manifest.yaml`) |
-| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
-| `--environment` | string | - | Filter deploys by environment |
-
-### detect-changes
-
-Determine which builds/deploys are triggered by file changes.
-
-```bash
-cascade detect-changes \
- --base-sha abc123 \
- --head-sha def456
-```
-
-#### Flags
-
-| Flag | Type | Required | Description |
-|------|------|----------|-------------|
-| `--config` | string | No | Path to manifest file (default: auto-detect) |
-| `--base-sha` | string | Yes | Base commit SHA |
-| `--head-sha` | string | Yes | Head commit SHA |
+### How the manifest is located
-#### Output
+Most commands (`generate-workflow`, `verify`, `plan`, `graph`, `status`, and the
+promotion lifecycle commands) auto-detect the manifest at `.github/manifest.yaml` when
+`--config` is not given. Two commands are the exception: `parse-config` and
+`detect-changes` default `--config` to the literal path `cicd-config.yaml` and do NOT
+auto-detect. Pass `--config` explicitly to point either one at a different file.
-```json
-{
- "triggered_builds": ["app"],
- "triggered_deploys": ["cdk", "services"],
- "has_changes": true,
- "changed_files": [
- "src/main.go",
- "cdk/stack.ts"
- ]
-}
-```
+## Commands
-#### Logic
+Commands are grouped by how often you reach for them:
-1. Get the changed file list between base and head
-2. For each build/deploy, check whether any changed file matches its triggers
-3. Build-linked deploys inherit triggers from referenced builds
+- **Everyday**: `version`, `init`, `generate-workflow`, `verify`, `plan`, `status`, `graph`
+- **Preview and inspect**: `simulate`, `parse-config`, `detect-changes`
+- **Promotion lifecycle**: `orchestrate`, `promote`, `hotfix`, `rollback`
+- **Releases and versioning**: `next-version`, `generate-changelog`, `manage-release`
+- **Multi-repo**: `external`
+- **Setup and governance**: `schema`, `environments`, `branch-protection`, `reconcile`
+- **Maintenance**: `reset`
-### generate-changelog
+### version
-Generate a markdown changelog from conventional commits.
+Display version information.
```bash
-cascade generate-changelog \
- --base-sha abc123 \
- --head-sha def456 \
- --repo owner/repo
+cascade version
```
-#### Flags
-
-| Flag | Type | Required | Description |
-|------|------|----------|-------------|
-| `--base-sha` | string | Yes | Base commit SHA |
-| `--head-sha` | string | Yes | Head commit SHA |
-| `--repo` | string | Yes | Repository (`owner/repo`) |
-| `--exclude-paths` | string | No | Comma-separated paths to exclude |
-| `--contributors` | bool | No | Include contributors section |
-
-#### Output
+Output:
-```json
-{
- "changelog": "### Features\n\n- Add user authentication ...",
- "has_breaking": false,
- "has_features": true,
- "has_fixes": true
-}
+```text
+cascade v0.9.1
+ commit: abc123d
+ built: 2026-01-15T10:30:00Z
```
-#### Conventional Commits
-
-| Type | Category | Included |
-|------|----------|----------|
-| feat | Features | Yes |
-| fix | Bug Fixes | Yes |
-| perf | Performance | Yes |
-| docs / chore / ci / test / style / refactor | Routine | No |
-
-Breaking changes detected via:
-- `!` suffix: `feat!: breaking change`
-- Footer: `BREAKING CHANGE: description` (case-sensitive, line start)
-
### init
-Scaffold a starter manifest and matching callback workflow stubs, verified through the real generator before anything is written.
+Scaffold a starter manifest and matching callback workflow stubs, verified through the
+real generator before anything is written.
```bash
# Two-environment pipeline (dev, prod) in the current directory
@@ -175,7 +81,15 @@ cascade init --envs staging,production --name my-service
cascade init --topology three-env --dry-run
```
-`init` renders `.github/manifest.yaml` plus build (and, when environments are set, deploy) stubs under `.github/workflows`, runs the manifest through parse, validation, and generation, and only then writes the files. It also drops a starter `.github/CODEOWNERS` and an `.github/aws-oidc-role.example.json` IAM trust-policy example for GitHub Actions OIDC; both carry placeholder owners and account IDs to replace. The manifest carries a `$schema` directive for editor autocomplete and validation. After running it, fill in the stub callbacks, commit, and run `cascade generate-workflow`. The scaffold is a verifying starter: `cascade init` then `cascade generate-workflow` leaves `cascade verify` clean, so you can wire a drift gate from the first commit.
+`init` renders `.github/manifest.yaml` plus build (and, when environments are set, deploy)
+stubs under `.github/workflows`, runs the manifest through parse, validation, and
+generation, and only then writes the files. It also drops a starter `.github/CODEOWNERS`
+and an `.github/aws-oidc-role.example.json` IAM trust-policy example for GitHub Actions
+OIDC; both carry placeholder owners and account IDs to replace. The manifest carries a
+`$schema` directive for editor autocomplete and validation. After running it, fill in the
+stub callbacks, commit, and run `cascade generate-workflow`. The scaffold is a verifying
+starter: `cascade init` then `cascade generate-workflow` leaves `cascade verify` clean, so
+you can wire a drift gate from the first commit.
#### Flags
@@ -189,11 +103,17 @@ cascade init --topology three-env --dry-run
| `--force`, `-f` | bool | false | Overwrite existing files |
| `--dry-run` | bool | false | Print what would be written without writing anything |
-Environment names are positional, not semantic: the last name is the release stage. An empty list (`--topology no-env`) produces a release-only project with no deploy stub. If any target file already exists and `--force` is not set, `init` aborts and lists the conflicts, writing nothing.
+Environment names are positional, not semantic: the last name is the release stage. An
+empty list (`--topology no-env`) produces a release-only project with no deploy stub. If
+any target file already exists and `--force` is not set, `init` aborts and lists the
+conflicts, writing nothing.
### generate-workflow
-Generate the orchestrate and promote workflows from the manifest.
+Generate the pipeline workflows from the manifest. A run emits the full set of
+cascade-owned workflows and the `manage-release` composite action; see
+[Generated workflows](/cascade/reference/generated-workflows/) for the exact file set and
+each workflow's anatomy.
```bash
cascade generate-workflow
@@ -213,29 +133,42 @@ cascade generate-workflow
| `--force`, `-f` | bool | false | Overwrite output file without prompting |
| `--commit` | bool | false | Commit generated files |
| `--push`, `-p` | bool | false | Push (implies `--commit`) |
-| `--orchestrate-only` | bool | false | Only generate `orchestrate.yaml` |
-| `--promote-only` | bool | false | Only generate `promote.yaml` |
+| `--orchestrate-only` | bool | false | Only generate the orchestrate workflow |
+| `--promote-only` | bool | false | Only generate the promote workflow |
-#### Generated Workflow Features
+#### Generated workflow features
-- **Output discovery**: parses callback workflows to discover declared outputs
-- **Dependency ordering**: topological sort honours `depends_on`
-- **Output chaining**: passes outputs from one callback to dependents
-- **Per-callback policies**: respects `run_policy`, `on_failure`, `retries`
-- **Environment overrides**: applies `env_inputs` per environment
-- **Publish step**: when `publish:` is configured, the promote workflow dispatches the callback once per build at the boundary where a prerelease becomes a release
+- **Output discovery**: parses callback workflows to discover declared outputs.
+- **Dependency ordering**: topological sort honours `depends_on`.
+- **Output chaining**: passes outputs from one callback to dependents.
+- **Per-callback policies**: respects `run_policy`, `on_failure`, `retries`.
+- **Environment overrides**: applies `env_inputs` per environment.
+- **Publish step**: when `publish:` is configured, the promote workflow dispatches the
+ callback once per build at the boundary where a prerelease becomes a release.
### verify
-Check that the committed workflow and action files match what the manifest would currently generate, without writing anything. `verify` is read-only: it never writes files, runs git, or modifies the repository.
+Check that the committed workflow and action files match what the manifest would currently
+generate, without writing anything. `verify` is read-only: it never writes files, runs
+git, or modifies the repository.
```bash
cascade verify
```
-`verify` reports drift when a file the manifest would generate is missing on disk, or when its committed bytes differ from the generated bytes. It covers the complete set of files `generate-workflow` emits (orchestrate, promote or release, external-update, validate-check, merge-queue, hotfix, rollback, pr-preview, drift-check, drift-comment, and the manage-release composite action), so adopters do not need to enumerate files by hand.
+`verify` reports drift when a file the manifest would generate is missing on disk, or when
+its committed bytes differ from the generated bytes. It covers the complete set of files
+`generate-workflow` emits (orchestrate, promote or release, external-update,
+validate-check, merge-queue, hotfix, rollback, pr-preview, drift-check, drift-comment, and
+the manage-release composite action), so adopters do not need to enumerate files by hand.
-`verify` also reports orphans: cascade-owned workflow files left behind in the workflows output directory that the manifest no longer plans (for example, after removing an environment or build). Only files carrying the cascade-generated marker are considered, so hand-written workflows in the same directory are never flagged. An orphan is reported as drift and exits 1 like any other drift. Pass `--allow-orphans` to skip this check when stale generated files are expected. Orphan detection reads the workflows output directory only and never deletes anything.
+`verify` also reports orphans: cascade-owned workflow files left behind in the workflows
+output directory that the manifest no longer plans (for example, after removing an
+environment or build). Only files carrying the cascade-generated marker are considered, so
+hand-written workflows in the same directory are never flagged. An orphan is reported as
+drift and exits `1` like any other drift. Pass `--allow-orphans` to skip this check when
+stale generated files are expected. Orphan detection reads the workflows output directory
+only and never deletes anything.
#### Flags
@@ -253,62 +186,44 @@ cascade verify
| Exit | Meaning |
|------|---------|
-| 0 | No drift: every generated file is present and byte-identical, and no orphaned generated files remain |
-| 1 | Drift detected: a generated file is missing, its committed bytes differ, or a cascade-owned file is orphaned (unless `--allow-orphans` is set) |
-| 2 | Error: the manifest is missing or invalid, or another operational failure prevented the check from running |
+| `0` | No drift: every generated file is present and byte-identical, and no orphaned generated files remain |
+| `1` | Drift detected: a generated file is missing, its committed bytes differ, or a cascade-owned file is orphaned (unless `--allow-orphans` is set) |
+| `2` | Operational failure: the manifest is missing or invalid, or another error prevented the check from running |
+
+These are the same three process exit codes described in [Exit codes](#exit-codes-1)
+below; `verify` is the command that returns `2` to distinguish an operational failure from
+drift.
#### Use in CI
-`verify` replaces a hand-rolled "regenerate and `git diff`" drift check with a single step. A CI job can run `cascade verify` to fail the build whenever committed workflows fall out of sync with the manifest:
+`verify` replaces a hand-rolled "regenerate and `git diff`" drift check with a single
+step. A CI job can run `cascade verify` to fail the build whenever committed workflows fall
+out of sync with the manifest:
```yaml
- run: cascade verify
```
-Rather than wire this job by hand, set `drift_check.enabled: true` in the manifest and `generate-workflow` emits the drift-check workflow for you. See [Drift-check workflow](/configuration/#drift-check-workflow-opt-in).
-
-### reconcile
-
-Adopt an external governed action-pin change (for example a Dependabot bump landing in a generated workflow) back into the manifest's `action_pins`, then regenerate every workflow the manifest produces so cascade's owned output agrees with it again. `reconcile` never pushes, commits, or merges; wiring a CI job (or running it by hand) to drive it, and to commit and push its result, stays the caller's job.
-
-```bash
-cascade reconcile --changed-file .github/workflows/orchestrate.yaml
-```
-
-`reconcile` reads the files named by `--changed-file` (repeatable) as data, scanning each line by line for a governed `uses:` reference, plus the manifest itself. It never reads a pin back out of a file the manifest generates: every generated file is exclusively a regenerate target, so a run that touches nothing relevant is a safe no-op and generation stays a pure offline function of the manifest. When a changed file carries a bump for an action cascade governs, `reconcile` writes that ref verbatim into the manifest's `action_pins`, keyed by action path, and regenerates. See [Action pinning](/configuration/#action-pinning) for what that write looks like under `pin_mode: tag` and `pin_mode: sha`.
-
-`reconcile` has three modes, selected by flag:
-
-| Mode | Flag | Behavior |
-|------|------|----------|
-| Default | (none) | Reconciles a user repo's manifest: adopts the bump into `action_pins` and regenerates. |
-| Detector | `--check` | Read-only: reports whether a governed pin changed and writes a data-only JSON artifact (`--check-output`) naming the changed refs; writes nothing else. |
-| Own-repo | `--own-repo` | Reconciles cascade's own `action_pins.yaml` manifest (a full re-marshal, since cascade owns that file) rather than a user manifest, and regenerates. |
-
-`--check` and `--own-repo` are mutually exclusive: the detector is read-only, and own-repo mode is a second write target, so combining them is rejected.
-
-#### Flags
-
-| Flag | Type | Default | Description |
-|------|------|---------|-------------|
-| `--config`, `-c` | string | `/.github/manifest.yaml` | Path to the manifest file |
-| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
-| `--root` | string | `.` | Repository root `reconcile` scans and writes relative to |
-| `--changed-file` | string (repeatable) | - | A changed source file to scan for a governed pin bump |
-| `--check` | bool | false | Read-only detector mode: report relevance and write a JSON artifact |
-| `--check-output` | string | `pin-reconcile-result.json` | Path to write the check-mode JSON artifact |
-| `--own-repo` | bool | false | Reconcile cascade's own `action_pins.yaml` manifest |
-| `--action-pins` | string | - | Path to `action_pins.yaml` (own-repo mode) |
+Rather than wire this job by hand, set `drift_check.enabled: true` in the manifest and
+`generate-workflow` emits the drift-check workflow for you. See
+[Generated workflows](/cascade/reference/generated-workflows/) for the opt-in companions.
### plan
-Preview, as a per-file unified diff, what `generate-workflow` would change in the committed workflow and action files, without writing anything. `plan` is read-only: it never writes files, runs git, or modifies the repository.
+Preview, as a per-file unified diff, what `generate-workflow` would change in the committed
+workflow and action files, without writing anything. `plan` is read-only: it never writes
+files, runs git, or modifies the repository.
```bash
cascade plan
```
-`plan` is the human-facing preview counterpart to `verify`. For every file the manifest would generate, it prints the diff between the committed bytes and the generated bytes: a new file appears as a whole-file add, a changed file as a unified hunk, and a file already in sync produces no diff. When nothing is pending it prints a single `plan: N files, no pending changes` line; otherwise it prints the diffs followed by a summary of how many files would change.
+`plan` is the human-facing preview counterpart to `verify`. For every file the manifest
+would generate, it prints the diff between the committed bytes and the generated bytes: a
+new file appears as a whole-file add, a changed file as a unified hunk, and a file already
+in sync produces no diff. When nothing is pending it prints a single
+`plan: N files, no pending changes` line; otherwise it prints the diffs followed by a
+summary of how many files would change.
#### Flags
@@ -324,182 +239,227 @@ cascade plan
| Exit | Meaning |
|------|---------|
-| 0 | Success, whether or not any diff was printed. `plan` is informational, so a pending change does not change the exit code |
+| `0` | Success, whether or not any diff was printed. `plan` is informational, so a pending change does not change the exit code |
| non-zero | Error: the manifest is missing or invalid, or another operational failure prevented the preview from running |
#### plan versus verify
-`plan` and `verify` are separate commands with separate contracts. `plan` is the human preview you read before regenerating: it shows the actual diff and always exits 0 on success, so it never fails a build on its own. `verify` is the pass/fail gate you wire into CI: it prints a terse drift report and exits 1 on drift, 2 on an operational failure, so it fails the build when committed workflows fall out of sync. Reach for `plan` at the terminal to see what would change, and for `verify` in a CI job to enforce that nothing has.
+`plan` and `verify` are separate commands with separate contracts. `plan` is the human
+preview you read before regenerating: it shows the actual diff and always exits `0` on
+success, so it never fails a build on its own. `verify` is the pass/fail gate you wire into
+CI: it prints a terse drift report and exits `1` on drift, `2` on an operational failure,
+so it fails the build when committed workflows fall out of sync. Reach for `plan` at the
+terminal to see what would change, and for `verify` in a CI job to enforce that nothing
+has.
-### branch-protection
+### status
-Produce the branch-protection settings for a cascade-managed trunk. The command has two modes. By default it emits the JSON body for an operator to apply and makes no API call. Pass `--apply` and cascade PUTs the body to GitHub's branch-protection API for you using a caller-supplied scoped token.
+Query deployed state recorded in the manifest. Every `status` command is read-only: it
+loads the manifest and prints recorded state, never writing files or calling GitHub. With
+no subcommand, `status` summarizes every environment together with `latest_release`.
```bash
-cascade branch-protection
+cascade status
```
-The output is a wrapper with two top-level keys:
-
-- `protection` is the exact body to PUT to the branches protection API.
-- `operator_todo` is companion guidance and is NOT part of the PUT body.
+#### Persistent flags
-Apply it by sending only the `.protection` object:
+These flags apply to `status` and all its subcommands.
-```bash
-cascade branch-protection | jq .protection | \
- gh api -X PUT repos/my-org/my-app/branches/main/protection --input -
-```
+| Flag | Type | Default | Description |
+|------|------|---------|-------------|
+| `--config`, `-c` | string | auto-detect | Path to manifest file (default `.github/manifest.yaml`) |
+| `--key` | string | `ci` | Top-level manifest key |
+| `--json` | bool | false | Output as JSON |
-#### Applying directly with `--apply`
+#### status env
-Instead of piping the JSON to `gh`, pass `--apply` and cascade sends the PUT itself. It transmits only the `.protection` object; the `operator_todo` guidance is never part of the request. The default emit behavior is unchanged: without `--apply` cascade still only prints or writes the JSON.
+Show the version, SHA, and commit metadata recorded for a single environment. Takes the
+environment name as a positional argument.
```bash
-cascade branch-protection --apply --repo my-org/my-app --branch main
+cascade status env prod
```
-With `--apply`, `--branch` is the real protection target rather than just a label on the guidance note. cascade resolves the target repository from `--repo`, falling back to the `GITHUB_REPOSITORY` environment variable, and the REST API base from `--api-url`, falling back to `GITHUB_API_URL` and then `https://api.github.com`. A missing token or repository is reported before any network call, and a non-2xx response from GitHub (for example a `403` from an under-scoped token) is surfaced with GitHub's own rejection message.
+#### status build
-Applying branch protection requires a token with repo-admin authority (the `Administration: write` permission). The workflow `GITHUB_TOKEN` does not carry that authority, so `--apply` needs a scoped personal access token supplied through `--token` or the `GITHUB_TOKEN` environment variable. Prefer the environment variable over the flag so the token stays out of process arguments and shell history.
+Show the build state (SHA, artifact id, built_at, tags) for a build within an environment.
+Takes the build name as a positional argument.
```bash
-GITHUB_TOKEN=ghp_your_admin_pat \
- cascade branch-protection --apply --repo my-org/my-app --branch main
+cascade status build app --env prod
```
-You can also pass `--output` alongside `--apply` to keep the emitted JSON on disk for your records; cascade writes the file first and then applies.
-
-#### What ends up required, and why it is safe
-
-The required status checks contain only the cascade-controlled `Setup` and `Finalize` jobs. These are the orchestrate workflow's two steps jobs; cascade knows their exact check-run names and both run on every pipeline run. Because of that, `.protection` applied as-is never creates a required check that can never report, so it never blocks a pull request on its own.
-
-The reusable-workflow caller jobs (validate, build, deploy) are deliberately left out of the required contexts. cascade knows each caller's display-name prefix (for example `Build (my-app)`) but not the inner job name that GitHub appends to form the real check-run context, which is ` / `. That inner job lives in your reusable workflow, which cascade does not author. Requiring a bare prefix would never match and would block every pull request, so cascade lists those prefixes under `operator_todo.complete_these_contexts` as ` / ` placeholders instead. Replace `` with the job name inside each reusable workflow, then add the completed strings to `required_status_checks.contexts` when you want them required.
+| Flag | Type | Default | Description |
+|------|------|---------|-------------|
+| `--env` | string | - | Environment name (required) |
-In the default emit mode the `--branch` flag only labels the guidance note (with `--apply` it is the real apply target, as described above). Either way the required contexts are the same across branches and environments because they are the orchestrate-workflow steps jobs, so `--env` would not change them and is not offered.
+#### status deploy
-This command complements the hotfix branch-protection advisory (see [Hotfix workflow](/workflows/#hotfix-workflow)): the advisory prints ready-to-run `gh` commands for env branches, while `branch-protection` emits the full PUT body for the trunk.
+Show the deploy state (SHA, deployed_at) for a deploy within an environment. Takes the
+deploy name as a positional argument.
-#### Flags
+```bash
+cascade status deploy services --env prod
+```
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--config`, `-c` | string | auto-detect | Path to manifest file |
-| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
-| `--branch` | string | `main` | Branch the protection targets; with `--apply` this is the real apply target, otherwise it labels the guidance note only and does not change the required contexts |
-| `--output`, `-o` | string | stdout | Write to this path instead of stdout (`-` also means stdout); honored alongside `--apply` to keep the JSON for your records |
-| `--apply` | bool | `false` | PUT the `.protection` body to GitHub instead of emitting JSON (requires a repo-admin token) |
-| `--token` | string | `GITHUB_TOKEN` | Scoped repo-admin token used for `--apply`; falls back to the `GITHUB_TOKEN` environment variable |
-| `--repo` | string | `GITHUB_REPOSITORY` | `owner/repo` the apply targets; falls back to the `GITHUB_REPOSITORY` environment variable |
-| `--api-url` | string | `GITHUB_API_URL` | REST API base for `--apply`; falls back to `GITHUB_API_URL`, then `https://api.github.com` |
+| `--env` | string | - | Environment name (required) |
-### environments
+#### status consistency
+
+Flag `env/*` integration branches that have no matching divergence in the manifest. A
+hotfix creates an `env/` branch that exists only while the environment is diverged;
+when the environment rejoins trunk the branch is deleted. A leftover `env/` branch
+with no diverged environment behind it is an orphan from an interrupted hotfix or manual
+branch creation.
-Emit a per-environment configuration file an operator applies to GitHub's Environments REST API. cascade emits the file; the operator applies it. cascade never calls the GitHub API.
+By default the command only reports. With `--fix` it deletes each flagged orphan branch on
+the remote (default `origin`). A branch that backs a diverged environment is never touched,
+and deleting an already-absent branch is a no-op, so `--fix` is safe to re-run.
```bash
-cascade environments
+cascade status consistency
+cascade status consistency --fix
```
-The output is a wrapper. The top-level `environments` is an array with one entry per manifest environment, in the manifest's `environments` order. Each entry has:
+| Flag | Type | Default | Description |
+|------|------|---------|-------------|
+| `--fix` | bool | false | Delete the flagged orphan `env/*` branches on the remote |
+| `--remote` | string | `origin` | Remote to inspect and, with `--fix`, delete orphan branches on |
-- `name` is the cascade environment name.
-- `gha_environment` is the GitHub Environment to configure; it defaults to `name`.
-- `environment` is the exact body to PUT to the environments API.
-- `operator_todo` is companion guidance and is NOT part of the PUT body.
+### graph
-Apply it by sending only the `.environment` object per entry:
+Render the manifest's generated pipeline as a Mermaid diagram on stdout. GitHub renders
+Mermaid natively in Markdown, so the output pastes directly into a README or pull request.
+`graph` is read-only: it never writes files, runs git, or modifies the repository. A
+missing or invalid manifest is reported as an error.
```bash
-cascade environments | jq -c '.environments[] | {gha_environment, environment}' | while read -r row; do
- env=$(jq -r .gha_environment <<<"$row")
- jq .environment <<<"$row" | gh api -X PUT "repos/my-org/my-app/environments/$env" --input -
-done
+cascade graph
+cascade graph --granularity env
+cascade graph --granularity stages > pipeline.mmd
```
-The per-environment settings come from the manifest under `config.environment_config.`:
+The `--granularity` flag chooses the projection:
-```yaml
-config:
- environments: [dev, test, prod]
- environment_config:
- prod:
- gha_environment: production
- required_reviewers: [team/ops]
- wait_timer: 10
- branch_policy: protected
- secrets: [MY_SECRET]
- variables: [REGION]
-```
+- `jobs` renders the full job dependency graph, with hard dependencies as solid arrows and
+ optional ordering-only ones as dotted arrows.
+- `stages` renders the coarse lifecycle flow from trunk through build, deploy, and promote
+ to release.
+- `env` renders the promotion state machine, including any hotfix divergence and rejoin.
+- `cross-repo` renders the multi-repo flow, a lane per repository with the primary
+ coordinating its dependent satellites and any satellite-to-primary notify edge.
-#### What the body carries, and what is operator guidance
+#### Flags
-The `.environment` body holds only the fields cascade can fully form from the manifest:
+| Flag | Type | Default | Description |
+|------|------|---------|-------------|
+| `--config`, `-c` | string | auto-detect | Path to manifest file (default `.github/manifest.yaml`) |
+| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
+| `--granularity` | string | `jobs` | Pipeline projection to render: `jobs`, `stages`, `env`, or `cross-repo` |
+| `--format` | string | `mermaid` | Diagram output format (supported value: `mermaid`) |
+| `--theme` | string | `cascade` | Diagram theme: `cascade`, `bland`, or a path to a JSON theme file |
-- `wait_timer` in minutes (0..43200).
-- `deployment_branch_policy`, mapped from the manifest `branch_policy`: `protected` becomes `{protected_branches: true, custom_branch_policies: false}`; `custom` becomes `{protected_branches: false, custom_branch_policies: true}`; `all` or unset becomes `null`, meaning all branches.
+See [Visualize the pipeline](/cascade/guides/visualize/) for the task-oriented walkthrough.
-Everything else cascade cannot fully form from the manifest is surfaced under `operator_todo` so the operator can finish it:
+### simulate
-- `operator_todo.required_reviewers` lists user and team slugs, NOT the body. The REST API requires a numeric reviewer id that the manifest does not carry, so the operator resolves each slug to an id and adds it to the body's `reviewers` array.
-- `operator_todo.secrets` and `operator_todo.variables` list the expected env-scoped secret and variable names. cascade emits names only, never values; the operator creates them with values through the environment-secrets and environment-variables APIs.
-- `branch_patterns` and `tag_patterns` (custom policy only) are created through the deployment-branch-policies API and are surfaced under `operator_todo`.
+Preview a hypothetical action against a clone of your manifest and print what would happen,
+without changing anything. The engine replays the real orchestration logic in record-only
+mode: it touches no GitHub, starts no container, runs no git command, and leaves the
+manifest untouched. It validates orchestration, the state transitions and run/skip/gate
+decisions, not your build and deploy scripts.
-The output is deterministic: the same manifest yields byte-identical output, and environments follow the manifest order.
+```bash
+cascade simulate promote
+cascade simulate release
+cascade simulate rollback --env prod
+cascade simulate hotfix --env uat --fix
+```
-This is the sibling of the [branch-protection](#branch-protection) command, using the same emit-a-config-file pattern (operator applies; cascade never calls the API).
+Each run prints a before/after state diff and an ordered effect sequence. The four
+subcommands are `promote`, `release`, `rollback`, and `hotfix`. See
+[Simulate and verify](/cascade/guides/simulate-and-verify/) for the full walkthrough,
+example output, and the deploy-stub model.
#### Flags
+The following flags are shared by every subcommand.
+
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--config`, `-c` | string | auto-detect | Path to manifest file |
-| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
-| `--output`, `-o` | string | stdout | Write to this path instead of stdout (`-` also means stdout) |
+| `--config` | string | auto-detect | Path to manifest file |
+| `--actor` | string | (none) | Actor performing the hypothetical action |
+| `--deploy-result` | string | (none) | Simulated outcome for a build or deploy callback, `name=success\|failure\|skipped` (repeatable) |
+| `--json` | bool | `false` | Output result as JSON |
-### manage-release
+Subcommand-specific flags: `promote` takes `--mode` (`default` or `cascade`) and
+`--target`; `rollback` takes `--env` (required), `--to`, and `--deployable`; `hotfix` takes
+`--env` (required), `--fix`, and `--merge-sha`; `release` takes only the shared flags.
-Manage GitHub releases.
+### parse-config
+
+Parse and validate the manifest and print it as JSON. This command defaults `--config` to
+the literal `cicd-config.yaml` path and does NOT auto-detect `.github/manifest.yaml`; pass
+`--config` to point it at another file.
```bash
-cascade manage-release \
- --action create \
- --repo owner/repo \
- --tag v1.0.0 \
- --changelog "Release notes here"
+cascade parse-config --config .github/manifest.yaml
```
#### Flags
-| Flag | Type | Required | Description |
-|------|------|----------|-------------|
-| `--action` | string | Yes | `create`, `update`, `lock`, `prerelease`, `publish`, `delete` |
-| `--repo` | string | Yes | Repository (`owner/repo`) |
-| `--tag` | string | Yes | Release tag |
-| `--environment` | string | No | Target environment |
-| `--sha` | string | No | Release commit SHA |
-| `--changelog` | string | No | Release notes (markdown) |
-| `--changelog-file` | string | No | Path to file containing notes (overrides `--changelog`) |
-| `--token` | string | No | GitHub token (or use `GITHUB_TOKEN`) |
-| `--previous-tag` | string | No | Previous tag for changelog comparison |
-| `--new-tag` | string | No | New semver tag (for `prerelease` action) |
-| `--delete-tag` | string | No | Tag to delete after publish (cleanup) |
-| `--create-tag` | bool | No | Create git tag on `create` |
+| Flag | Type | Default | Description |
+|------|------|---------|-------------|
+| `--config`, `-c` | string | `cicd-config.yaml` | Path to the manifest file (no auto-detection) |
+| `--output`, `-o` | string | `json` | Output format (`json`) |
-#### Actions
+### detect-changes
-| Action | Description |
-|--------|-------------|
-| `create` | Create a new release |
-| `update` | Update an existing release |
-| `lock` | Mark as pre-release |
-| `prerelease` | Re-tag a draft RC as a non-draft pre-release |
-| `publish` | Finalize a release (drops the RC suffix) |
-| `delete` | Delete a release |
+Determine which builds and deploys are triggered by file changes between two commits. Like
+`parse-config`, this command defaults `--config` to the literal `cicd-config.yaml` path and
+does NOT auto-detect `.github/manifest.yaml`.
+
+```bash
+cascade detect-changes \
+ --config .github/manifest.yaml \
+ --base-sha abc123 \
+ --head-sha def456
+```
+
+#### Flags
+
+| Flag | Type | Required | Default | Description |
+|------|------|----------|---------|-------------|
+| `--config`, `-c` | string | No | `cicd-config.yaml` | Path to the manifest file (no auto-detection) |
+| `--base-sha` | string | Yes | - | Base commit SHA |
+| `--head-sha` | string | Yes | - | Head commit SHA |
+
+#### Output
+
+```json
+{
+ "triggered_builds": ["app"],
+ "triggered_deploys": ["cdk", "services"],
+ "has_changes": true,
+ "changed_files": [
+ "src/main.go",
+ "cdk/stack.ts"
+ ]
+}
+```
+
+#### Logic
+
+1. Get the changed file list between base and head.
+2. For each build or deploy, check whether any changed file matches its triggers.
+3. Build-linked deploys inherit triggers from referenced builds.
### orchestrate
-Main CI/CD orchestration command with subcommands.
+Main orchestration command with subcommands. It drives the generated orchestrate workflow.
#### orchestrate setup
@@ -511,17 +471,15 @@ cascade orchestrate setup \
--sha def456
```
-##### Flags
-
| Flag | Type | Required | Description |
|------|------|----------|-------------|
-| `--config` | string | No | Path to manifest file (default: auto-detect) |
-| `--manifest-key` | string | No | Top-level key (default: `ci`) |
+| `--config` | string | No | Path to manifest file (auto-detect) |
+| `--manifest-key` | string | No | Top-level key (default `ci`) |
| `--environment` | string | Yes | Target environment (empty for no-env setup) |
| `--sha` | string | No | Head SHA (default: current HEAD) |
| `--gha-output` | bool | No | Write outputs to `$GITHUB_OUTPUT` |
-##### Output
+Output:
```json
{
@@ -550,8 +508,6 @@ cascade orchestrate finalize \
--deploy-results "cdk:success,services:success"
```
-##### Flags
-
| Flag | Type | Required | Description |
|------|------|----------|-------------|
| `--environment` | string | Yes | Target environment |
@@ -562,9 +518,10 @@ cascade orchestrate finalize \
### promote
-Promotion command with subcommands.
+Promotion command with subcommands. For the operator recipe see
+[Promote a release](/cascade/guides/promote/).
-#### Persistent Flags
+#### Persistent flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
@@ -585,8 +542,6 @@ cascade promote preflight \
--rollback-on-failure
```
-##### Flags
-
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--mode` | string | `default` | `default` or cascade target (e.g., `dev-to-prod`) |
@@ -596,9 +551,15 @@ cascade promote preflight \
| `--rollback-on-failure` | bool | true | Revert successful deploys if any fails |
| `--allow-downgrade` | bool | false | Permit promoting an older version onto an env (a downgrade). Blocked by default; prod always requires this flag |
-A promotion that would place a strictly older semver version onto an env than the version it currently holds is a downgrade. Preflight blocks it by default, naming both versions and the env. Pass `--allow-downgrade` to permit it. The terminal (prod) env always requires the flag, even when a lower env in the same cascade already permitted the same downgrade. Equal versions are an idempotent re-promote and are never treated as a downgrade. When either version is not parseable as semver the gate fails open with a warning rather than blocking, so non-semver pipelines keep working.
+A promotion that would place a strictly older semver version onto an env than the version
+it currently holds is a downgrade. Preflight blocks it by default, naming both versions and
+the env. Pass `--allow-downgrade` to permit it. The terminal (prod) env always requires the
+flag, even when a lower env in the same cascade already permitted the same downgrade. Equal
+versions are an idempotent re-promote and are never treated as a downgrade. When either
+version is not parseable as semver the gate fails open with a warning rather than blocking,
+so non-semver pipelines keep working.
-##### Output
+Output:
```json
{
@@ -624,31 +585,44 @@ Update state after promotion deploys complete.
cascade promote finalize \
--promotion-result "$RESULT_JSON" \
--repo owner/repo \
- --run-id "$GITHUB_RUN_ID" \
+ --run-id "$RUN_ID" \
--commit-push
```
-##### Flags
-
| Flag | Type | Required | Description |
|------|------|----------|-------------|
| `--promotion-result` | string | Yes | JSON from `preflight` output |
| `--repo` | string | No | Repository (`owner/name`) for job query |
-| `--run-id` | string | No | Workflow run ID for job query |
+| `--run-id` | string | No | Workflow run id for job query |
| `--commit-push` | bool | No | Commit and push state changes |
### hotfix
-Apply one or more trunk commits onto an environment pinned to an older base. A hotfix elevates the commit set bottom-up across the environment chain, up to and including the target environment, on each environment's `env/` integration branch. The fixes must already be on trunk; cascade refuses to apply any commit that is not an ancestor of trunk tip. The subcommands compute and validate the hotfix and write its final state; the cherry-pick, build, and deploy run in the generated `cascade-hotfix.yaml` workflow. See the Hotfix section of [Workflows](/cascade/workflows/) for the full flow.
+Apply one or more trunk commits onto an environment pinned to an older base. A hotfix
+elevates the commit set bottom-up across the environment chain, up to and including the
+target environment, on each environment's `env/` integration branch. The fixes must
+already be on trunk; cascade refuses to apply any commit that is not an ancestor of trunk
+tip. The subcommands compute and validate the hotfix and write its final state; the
+cherry-pick, build, and deploy run in the generated `cascade-hotfix.yaml` workflow. See
+[Run a hotfix](/cascade/guides/hotfix/) for the full flow.
#### hotfix plan
-Validate a hotfix request and compute the integration-branch plan. It enforces, in order: trunk ancestry of every fix, target-environment eligibility (a configured environment that is not the first; prod is allowed), no-op detection when a fix is already present, the single-flight open-pull-request gate, and `env/` branch reconciliation. With `--dry-run` nothing is mutated (the env branches are planned but not created).
+Validate a hotfix request and compute the integration-branch plan. It enforces, in order:
+trunk ancestry of every fix, target-environment eligibility (a configured environment that
+is not the first; prod is allowed), no-op detection when a fix is already present, the
+single-flight open-pull-request gate, and `env/` branch reconciliation. With
+`--dry-run` nothing is mutated (the env branches are planned but not created).
-Supply the fixes with one of two mutually exclusive flags, exactly one of which is required:
+Supply the fixes with one of two mutually exclusive flags, exactly one of which is
+required:
- `--commit ` applies a single commit to the target environment.
-- `--commits ` takes a comma-delimited set and elevates it bottom-up across the chain, from the environment above the first up to and including `--target-env`. On this path each (commit, environment) pair is skipped when the commit is already an ancestor of that environment's state SHA or already in its recorded `patches`; an environment whose whole set is already present is a no-op and the chain moves on.
+- `--commits ` takes a comma-delimited set and elevates it bottom-up across
+ the chain, from the environment above the first up to and including `--target-env`. On
+ this path each (commit, environment) pair is skipped when the commit is already an
+ ancestor of that environment's state SHA or already in its recorded `patches`; an
+ environment whose whole set is already present is a no-op and the chain moves on.
```bash
cascade hotfix plan \
@@ -666,24 +640,20 @@ cascade hotfix plan \
--gha-output
```
-##### Flags
-
| Flag | Type | Required | Description |
|------|------|----------|-------------|
-| `--config`, `-c` | string | No | Path to manifest file (default: `.github/manifest.yaml`) |
-| `--key` | string | No | Top-level manifest key (default: `ci`) |
+| `--config`, `-c` | string | No | Path to manifest file (default `.github/manifest.yaml`) |
+| `--key` | string | No | Top-level manifest key (default `ci`) |
| `--commit` | string | One of `commit`/`commits` | Single trunk commit (SHA or ref) carrying the fix; single-env path |
| `--commits` | string | One of `commit`/`commits` | Comma-delimited trunk commits to elevate across the env chain up to `--target-env` |
| `--target-env` | string | Yes | Environment to hotfix |
-| `--actor` | string | No | Actor recorded on the plan (default: `$GITHUB_ACTOR`) |
-| `--remote` | string | No | Git remote env branches live on (default: `origin`) |
+| `--actor` | string | No | Actor recorded on the plan (default `$GITHUB_ACTOR`) |
+| `--remote` | string | No | Git remote env branches live on (default `origin`) |
| `--repo` | string | No | `owner/repo` for single-flight pull-request lookup via `gh` (default: skip the check) |
| `--dry-run` | bool | No | Compute the plan without mutating anything |
| `--json` | bool | No | Output the plan as JSON |
| `--gha-output` | bool | No | Write outputs to `$GITHUB_OUTPUT` for workflow consumption |
-##### Output
-
With `--json`:
```json
@@ -701,11 +671,21 @@ With `--json`:
}
```
-The GHA output writes `target_env`, `fix_sha`, `branch`, `base_sha`, `no_op`, `branch_created`, `hotfix_version_candidate`, `conflict_expected`, `dry_run`, and the `protection_suggestions` commands (as JSON and as multiline text). On the `--commits` path the plan also writes `env_sequence` (the environments to walk bottom-up) and a `commits_` list per environment that the apply job replays in order.
+The GHA output writes `target_env`, `fix_sha`, `branch`, `base_sha`, `no_op`,
+`branch_created`, `hotfix_version_candidate`, `conflict_expected`, `dry_run`, and the
+`protection_suggestions` commands (as JSON and as multiline text). On the `--commits` path
+the plan also writes `env_sequence` (the environments to walk bottom-up) and a
+`commits_` list per environment that the apply job replays in order.
#### hotfix finalize
-Write the diverged state, tag, and release for a merged hotfix. Run after the resolution pull request merges and the build and deploy succeed. It cross-checks the merge SHA against the `env/` branch tip, allocates the next free hotfix version, snapshots the prior state into the rollback ring, writes the divergence fields and substates, commits the manifest to trunk with the rebase-retry push, and creates the hotfix tag and release object. The verb is idempotent on identical inputs: a rerun after the state already records the merge SHA is a no-op.
+Write the diverged state, tag, and release for a merged hotfix. Run after the resolution
+pull request merges and the build and deploy succeed. It cross-checks the merge SHA against
+the `env/` branch tip, allocates the next free hotfix version, snapshots the prior
+state into the rollback ring, writes the divergence fields and substates, commits the
+manifest to trunk with the rebase-retry push, and creates the hotfix tag and release
+object. The verb is idempotent on identical inputs: a rerun after the state already records
+the merge SHA is a no-op.
```bash
cascade hotfix finalize \
@@ -717,38 +697,51 @@ cascade hotfix finalize \
--deploy-result app=success
```
-##### Flags
-
| Flag | Type | Required | Description |
|------|------|----------|-------------|
-| `--config`, `-c` | string | No | Path to manifest file (default: `.github/manifest.yaml`) |
-| `--key` | string | No | Top-level manifest key (default: `ci`) |
+| `--config`, `-c` | string | No | Path to manifest file (default `.github/manifest.yaml`) |
+| `--key` | string | No | Top-level manifest key (default `ci`) |
| `--target-env` | string | Yes | Environment to finalize |
| `--merge-sha` | string | Yes | Tip of `env/` after the resolution pull request merged |
| `--fix-sha` | string | Yes | Trunk commit(s) the hotfix carries; comma-delimited for a multi-commit set. Every commit applied to the environment is appended to its recorded `patches` (commits already present in that environment are skipped) |
| `--base-sha` | string | Yes | Trunk anchor the integration branch diverged from |
-| `--actor` | string | No | Actor recorded on the state (default: `$GITHUB_ACTOR`) |
+| `--actor` | string | No | Actor recorded on the state (default `$GITHUB_ACTOR`) |
| `--dry-run` | bool | No | Validate and compute without writing state, tags, or releases |
| `--build-result` | string | No | Build result as `name=result` (repeatable) |
| `--deploy-result` | string | No | Deploy result as `name=result` (repeatable) |
-Only successful build and deploy results update the per-build and per-deploy substates. For a prerelease-environment target the hotfix release is promoted to a GitHub prerelease, superseding that environment's current prerelease object; for other environments it stays a draft.
+Only successful build and deploy results update the per-build and per-deploy substates. For
+a prerelease-environment target the hotfix release is promoted to a GitHub prerelease,
+superseding that environment's current prerelease object; for other environments it stays a
+draft.
### rollback
-Re-promote a prior known-good version or SHA to an environment. Rollback resolves the target from existing deployment state and, when needed, the git history of the manifest, then re-applies that SHA and version using the same state-write path as a normal promotion. There is no separate deploy code path.
+Re-promote a prior known-good version or SHA to an environment. Rollback resolves the
+target from existing deployment state and, when needed, the git history of the manifest,
+then re-applies that SHA and version using the same state-write path as a normal promotion.
+There is no separate deploy code path. For the operator recipe see
+[Roll back an environment](/cascade/guides/rollback/).
-`rollback` has a flat operator form plus two subcommands, `preflight` and `finalize`, that the generated rollback workflow drives: a read-only preflight that resolves the target, and a finalize that applies the state write and pushes it back to trunk.
+`rollback` has a flat operator form plus two subcommands, `preflight` and `finalize`, that
+the generated rollback workflow drives: a read-only preflight that resolves the target, and
+a finalize that applies the state write and pushes it back to trunk.
Resolution order for `--to `:
1. The environment's current recorded state (and per-deployable state).
2. The environment's deploy-history ring, newest first.
-3. The manifest's git history, newest first (recovers a deployment the manifest has already moved past).
+3. The manifest's git history, newest first (recovers a deployment the manifest has already
+ moved past).
-When `--to` is omitted, rollback resolves the previous version: the newest deploy-history ring entry that differs from the current state, falling back to the newest distinct prior state from manifest history. A SHA may be given in full or as a short prefix of 7 or more characters. Use `--deployable` to scope the rollback to a single deployable's recorded version.
+When `--to` is omitted, rollback resolves the previous version: the newest deploy-history
+ring entry that differs from the current state, falling back to the newest distinct prior
+state from manifest history. A SHA may be given in full or as a short prefix of 7 or more
+characters. Use `--deployable` to scope the rollback to a single deployable's recorded
+version.
-Without `--dry-run` the flat form writes the re-promotion to the manifest; with `--dry-run` it prints the resolved plan and makes no changes.
+Without `--dry-run` the flat form writes the re-promotion to the manifest; with `--dry-run`
+it prints the resolved plan and makes no changes.
```bash
cascade rollback --env prod --to v1.2.2
@@ -758,13 +751,19 @@ cascade rollback --env prod --to v1.2.2 --deployable services
#### First-environment guard
-The first environment in the chain (the build target) tracks trunk and is never promoted into, so its deploy-history ring is structurally empty and it has no prior target to resolve. Rollback fails fast on the first environment rather than resolve a silent wrong target from an empty or stale ring. The trunk-native undo for the first environment is a revert merge to the trunk branch, not a rollback. The guard is inert when no parsed config is available to identify the first environment, so a state-only manifest still resolves through the normal path.
+The first environment in the chain (the build target) tracks trunk and is never promoted
+into, so its deploy-history ring is structurally empty and it has no prior target to
+resolve. Rollback fails fast on the first environment rather than resolve a silent wrong
+target from an empty or stale ring. The trunk-native undo for the first environment is a
+revert merge to the trunk branch, not a rollback. The guard is inert when no parsed config
+is available to identify the first environment, so a state-only manifest still resolves
+through the normal path.
#### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--config`, `-c` | string | auto-detect | Path to manifest file (default: `.github/manifest.yaml`) |
+| `--config`, `-c` | string | auto-detect | Path to manifest file (default `.github/manifest.yaml`) |
| `--key` | string | `ci` | Top-level manifest key |
| `--env` | string | - | Target environment to roll back (required) |
| `--to` | string | previous version | Prior version or SHA to re-promote (defaults to the previous version) |
@@ -775,17 +774,19 @@ The first environment in the chain (the build target) tracks trunk and is never
#### rollback preflight
-Resolve the rollback target read-only and report it. This is the first stage of the generated rollback workflow. It resolves the target SHA and version (from live state, the deploy-history ring, or manifest history) and, with `--gha-output`, writes `target_env`, `target_sha`, `target_version`, `target_source`, and `can_proceed` to `$GITHUB_OUTPUT` for the deploy and finalize jobs. It writes no manifest state.
+Resolve the rollback target read-only and report it. This is the first stage of the
+generated rollback workflow. It resolves the target SHA and version (from live state, the
+deploy-history ring, or manifest history) and, with `--gha-output`, writes `target_env`,
+`target_sha`, `target_version`, `target_source`, and `can_proceed` to `$GITHUB_OUTPUT` for
+the deploy and finalize jobs. It writes no manifest state.
```bash
cascade rollback preflight --env prod --gha-output
```
-##### Flags
-
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--config`, `-c` | string | auto-detect | Path to manifest file (default: `.github/manifest.yaml`) |
+| `--config`, `-c` | string | auto-detect | Path to manifest file (default `.github/manifest.yaml`) |
| `--key` | string | `ci` | Top-level manifest key |
| `--env` | string | - | Target environment to roll back (required) |
| `--to` | string | previous version | Prior version or SHA to re-promote (defaults to the previous version) |
@@ -795,17 +796,21 @@ cascade rollback preflight --env prod --gha-output
#### rollback finalize
-Apply a resolved rollback and persist the updated manifest. This is the final stage of the generated rollback workflow. It resolves the target, applies it to state (marking the environment diverged so forward-promotion guards treat it as off-trunk until a promotion rejoins it), and, with `--commit-push`, commits the manifest back to the trunk branch. The state write is gated on the reported deploy results: if any in-scope deploy reports `failure` or `cancelled`, or no in-scope deploy succeeded, the write is aborted and trunk state is left unchanged.
+Apply a resolved rollback and persist the updated manifest. This is the final stage of the
+generated rollback workflow. It resolves the target, applies it to state (marking the
+environment diverged so forward-promotion guards treat it as off-trunk until a promotion
+rejoins it), and, with `--commit-push`, commits the manifest back to the trunk branch. The
+state write is gated on the reported deploy results: if any in-scope deploy reports
+`failure` or `cancelled`, or no in-scope deploy succeeded, the write is aborted and trunk
+state is left unchanged.
```bash
cascade rollback finalize --env prod --commit-push
```
-##### Flags
-
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--config`, `-c` | string | auto-detect | Path to manifest file (default: `.github/manifest.yaml`) |
+| `--config`, `-c` | string | auto-detect | Path to manifest file (default `.github/manifest.yaml`) |
| `--key` | string | `ci` | Top-level manifest key |
| `--env` | string | - | Target environment to roll back (required) |
| `--to` | string | previous version | Prior version or SHA to re-promote (defaults to the previous version) |
@@ -824,8 +829,6 @@ cascade next-version \
--head-sha def456
```
-#### Flags
-
| Flag | Type | Required | Description |
|------|------|----------|-------------|
| `--config`, `-c` | string | No | Path to manifest file |
@@ -835,14 +838,107 @@ cascade next-version \
| `--json` | bool | No | Output as JSON |
Bump rules:
-- Breaking change (`feat!`, `BREAKING CHANGE:`) triggers a major bump
-- Feature (`feat`) triggers a minor bump
-- Fix (`fix`) triggers a patch bump
-- Pre-release environments append an RC suffix (e.g., `v1.3.0-rc.0`)
+
+- Breaking change (`feat!`, `BREAKING CHANGE:`) triggers a major bump.
+- Feature (`feat`) triggers a minor bump.
+- Fix (`fix`) triggers a patch bump.
+- Pre-release environments append an RC suffix (e.g., `v1.3.0-rc.0`).
+
+### generate-changelog
+
+Generate a markdown changelog from conventional commits.
+
+```bash
+cascade generate-changelog \
+ --base-sha abc123 \
+ --head-sha def456 \
+ --repo owner/repo
+```
+
+| Flag | Type | Required | Description |
+|------|------|----------|-------------|
+| `--base-sha` | string | Yes | Base commit SHA |
+| `--head-sha` | string | Yes | Head commit SHA |
+| `--repo` | string | Yes | Repository (`owner/repo`) |
+| `--exclude-paths` | string | No | Comma-separated paths to exclude |
+| `--contributors` | bool | No | Include contributors section |
+
+Output:
+
+```json
+{
+ "changelog": "### Features\n\n- Add user authentication ...",
+ "has_breaking": false,
+ "has_features": true,
+ "has_fixes": true
+}
+```
+
+Conventional-commit mapping:
+
+| Type | Category | Included |
+|------|----------|----------|
+| feat | Features | Yes |
+| fix | Bug Fixes | Yes |
+| perf | Performance | Yes |
+| docs / chore / ci / test / style / refactor | Routine | No |
+
+Breaking changes are detected via:
+
+- `!` suffix: `feat!: breaking change`
+- Footer: `BREAKING CHANGE: description` (case-sensitive, line start)
+
+### manage-release
+
+Manage GitHub releases.
+
+```bash
+cascade manage-release \
+ --action create \
+ --repo owner/repo \
+ --tag v1.2.0 \
+ --changelog "Release notes here"
+```
+
+| Flag | Type | Required | Description |
+|------|------|----------|-------------|
+| `--action` | string | Yes | `create`, `update`, `lock`, `prerelease`, `publish`, `delete` |
+| `--repo` | string | Yes | Repository (`owner/repo`) |
+| `--tag` | string | Yes | Release tag |
+| `--environment` | string | No | Target environment |
+| `--sha` | string | No | Release commit SHA |
+| `--changelog` | string | No | Release notes (markdown) |
+| `--changelog-file` | string | No | Path to file containing notes (overrides `--changelog`) |
+| `--token` | string | No | GitHub token (or use `GITHUB_TOKEN`) |
+| `--previous-tag` | string | No | Previous tag for changelog comparison |
+| `--new-tag` | string | No | New semver tag (for `prerelease` action) |
+| `--delete-tag` | string | No | Tag to delete after publish (cleanup) |
+| `--create-tag` | bool | No | Create git tag on `create` |
+
+Actions:
+
+| Action | Description |
+|--------|-------------|
+| `create` | Create a new release |
+| `update` | Update an existing release |
+| `lock` | Mark as pre-release |
+| `prerelease` | Re-tag a draft RC as a non-draft pre-release |
+| `publish` | Finalize a release (drops the RC suffix) |
+| `delete` | Delete a release |
### external
-Commands for multi-repo orchestration.
+Commands for multi-repo orchestration. `--config`, `--manifest-key`, and `--gha-output` are
+persistent flags on the `external` parent command itself, so they apply to every
+subcommand. See [Coordinate multiple repos](/cascade/guides/multi-repo/) for the workflow.
+
+#### external persistent flags
+
+| Flag | Type | Default | Description |
+|------|------|---------|-------------|
+| `--config` | string | - | Path to manifest file |
+| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
+| `--gha-output` | bool | false | Write outputs to `$GITHUB_OUTPUT` |
#### external update
@@ -858,11 +954,8 @@ cascade external update \
--artifacts '{"image_tag": "cdk-abc123"}'
```
-##### Flags
-
| Flag | Type | Required | Description |
|------|------|----------|-------------|
-| `--config` | string | No | Path to manifest file |
| `--source-repo` | string | Yes | Source repository (e.g., `org/cdk-infra`) |
| `--deploy-name` | string | Yes | Deploy name |
| `--environment` | string | Yes | Target environment |
@@ -871,32 +964,14 @@ cascade external update \
| `--artifacts` | string | No | Artifacts JSON |
| `--dry-run` | bool | No | Preview mode |
-This is typically called by the satellite's `external-update.yaml` workflow after deploying to dev.
-
-### reset
-
-Reset releases and state for testing.
-
-```bash
-cascade reset --state --push
-```
-
-#### Flags
-
-| Flag | Type | Default | Description |
-|------|------|---------|-------------|
-| `--state` | bool | false | Reset the state section in the manifest |
-| `--dry-run` | bool | false | Preview without executing |
-| `--push` | bool | false | Push state changes (requires `--state`) |
-| `--repo` | string | cwd | Path to repository |
-| `--config` | string | auto-detect | Path to manifest file |
-| `--manifest-key` | string | `ci` | Top-level key |
-
-Deletes all GitHub releases and tags. With `--state`, also clears the state section.
+This is typically called by the satellite's `external-update.yaml` workflow after deploying
+to dev.
### schema
-Print the manifest JSON Schema. Point your editor at it for autocomplete, type checking, and hover docs while authoring `.github/manifest.yaml`. See [Editor support](/cascade/configuration/#editor-support) for registration.
+Print the manifest JSON Schema. Point your editor at it for autocomplete, type checking,
+and hover docs while authoring `.github/manifest.yaml`. See the
+[Manifest reference](/cascade/reference/manifest/) for editor registration.
```bash
# Print the schema to stdout
@@ -906,157 +981,286 @@ cascade schema
cascade schema --output manifest.schema.json
```
-#### Flags
-
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--output`, `-o` | string | stdout | Write the schema to a file instead of stdout |
-The same schema is published at `https://stablekernel.github.io/cascade/manifest.schema.json`. `parse-config` remains the authority for semantic and cross-field rules; the schema covers structure, types, enums, and hover docs.
+The same schema is published at
+`https://stablekernel.github.io/cascade/manifest.schema.json`. `parse-config` remains the
+authority for semantic and cross-field rules; the schema covers structure, types, enums,
+and hover docs.
-### simulate
+### environments
-Preview a hypothetical action against a clone of your manifest and print what would happen, without changing anything. The engine replays the real orchestration logic in record-only mode: it touches no GitHub, starts no container, runs no git command, and leaves the manifest untouched. It validates orchestration, the state transitions and run/skip/gate decisions, not your build and deploy scripts.
+Emit a per-environment configuration file an operator applies to GitHub's Environments REST
+API. cascade emits the file; the operator applies it. cascade never calls the GitHub API.
```bash
-cascade simulate promote
-cascade simulate release
-cascade simulate rollback --env prod
-cascade simulate hotfix --env uat --fix
+cascade environments
```
-Each run prints a before/after state diff and an ordered effect sequence. The four subcommands are `promote`, `release`, `rollback`, and `hotfix`. See [Local Simulation](/cascade/simulate/) for the full walkthrough, example output, and the deploy-stub model.
+The output is a wrapper. The top-level `environments` is an array with one entry per
+manifest environment, in the manifest's `environments` order. Each entry has:
-#### Flags
+- `name` is the cascade environment name.
+- `gha_environment` is the GitHub Environment to configure; it defaults to `name`.
+- `environment` is the exact body to PUT to the environments API.
+- `operator_todo` is companion guidance and is NOT part of the PUT body.
-The following flags are shared by every subcommand.
+Apply it by sending only the `.environment` object per entry:
-| Flag | Type | Default | Description |
-|------|------|---------|-------------|
-| `--config` | string | auto-detect | Path to manifest file |
-| `--actor` | string | (none) | Actor performing the hypothetical action |
-| `--deploy-result` | string | (none) | Simulated outcome for a build or deploy callback, `name=success\|failure\|skipped` (repeatable) |
-| `--json` | bool | `false` | Output result as JSON |
+```bash
+cascade environments | jq -c '.environments[] | {gha_environment, environment}' | while read -r row; do
+ env=$(jq -r .gha_environment <<<"$row")
+ jq .environment <<<"$row" | gh api -X PUT "repos/my-org/my-app/environments/$env" --input -
+done
+```
-Subcommand-specific flags: `promote` takes `--mode` (`default` or `cascade`) and `--target`; `rollback` takes `--env` (required), `--to`, and `--deployable`; `hotfix` takes `--env` (required), `--fix`, and `--merge-sha`; `release` takes only the shared flags.
+The per-environment settings come from the manifest under
+`config.environment_config.`:
-### status
+```yaml
+config:
+ environments: [dev, test, prod]
+ environment_config:
+ prod:
+ gha_environment: production
+ required_reviewers: [team/ops]
+ wait_timer: 10
+ branch_policy: protected
+ secrets: [MY_SECRET]
+ variables: [REGION]
+```
-Query deployed state recorded in the manifest. Every `status` command is read-only: it loads the manifest and prints recorded state, never writing files or calling GitHub. With no subcommand, `status` summarizes every environment together with `latest_release`.
+#### What the body carries, and what is operator guidance
-```bash
-cascade status
-```
+The `.environment` body holds only the fields cascade can fully form from the manifest:
-#### Persistent Flags
+- `wait_timer` in minutes (0..43200).
+- `deployment_branch_policy`, mapped from the manifest `branch_policy`: `protected` becomes
+ `{protected_branches: true, custom_branch_policies: false}`; `custom` becomes
+ `{protected_branches: false, custom_branch_policies: true}`; `all` or unset becomes
+ `null`, meaning all branches.
+
+Everything else cascade cannot fully form from the manifest is surfaced under
+`operator_todo` so the operator can finish it:
+
+- `operator_todo.required_reviewers` lists user and team slugs, NOT the body. The REST API
+ requires a numeric reviewer id that the manifest does not carry, so the operator resolves
+ each slug to an id and adds it to the body's `reviewers` array.
+- `operator_todo.secrets` and `operator_todo.variables` list the expected env-scoped secret
+ and variable names. cascade emits names only, never values; the operator creates them
+ with values through the environment-secrets and environment-variables APIs.
+- `branch_patterns` and `tag_patterns` (custom policy only) are created through the
+ deployment-branch-policies API and are surfaced under `operator_todo`.
+
+The output is deterministic: the same manifest yields byte-identical output, and
+environments follow the manifest order. This is the sibling of the `branch-protection`
+command, using the same emit-a-config-file pattern (operator applies; cascade never calls
+the API). See [Add or change environments](/cascade/guides/environments/) for the
+task-oriented walkthrough.
-These flags apply to `status` and all its subcommands.
+#### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--config`, `-c` | string | auto-detect | Path to manifest file (default: `.github/manifest.yaml`) |
-| `--key` | string | `ci` | Top-level manifest key |
-| `--json` | bool | false | Output as JSON |
+| `--config`, `-c` | string | auto-detect | Path to manifest file |
+| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
+| `--output`, `-o` | string | stdout | Write to this path instead of stdout (`-` also means stdout) |
-#### status env
+### branch-protection
-Show the version, SHA, and commit metadata recorded for a single environment. Takes the environment name as a positional argument.
+Produce the branch-protection settings for a cascade-managed trunk. The command has two
+modes. By default it emits the JSON body for an operator to apply and makes no API call.
+Pass `--apply` and cascade PUTs the body to GitHub's branch-protection API for you using a
+caller-supplied scoped token.
```bash
-cascade status env prod
+cascade branch-protection
```
-#### status build
+The output is a wrapper with two top-level keys:
-Show the build state (SHA, artifact id, built_at, tags) for a build within an environment. Takes the build name as a positional argument.
+- `protection` is the exact body to PUT to the branches protection API.
+- `operator_todo` is companion guidance and is NOT part of the PUT body.
+
+Apply it by sending only the `.protection` object:
```bash
-cascade status build app --env prod
+cascade branch-protection | jq .protection | \
+ gh api -X PUT repos/my-org/my-app/branches/main/protection --input -
```
-##### Flags
-
-| Flag | Type | Default | Description |
-|------|------|---------|-------------|
-| `--env` | string | - | Environment name (required) |
-
-#### status deploy
+#### Applying directly with `--apply`
-Show the deploy state (SHA, deployed_at) for a deploy within an environment. Takes the deploy name as a positional argument.
+Instead of piping the JSON to `gh`, pass `--apply` and cascade sends the PUT itself. It
+transmits only the `.protection` object; the `operator_todo` guidance is never part of the
+request. The default emit behavior is unchanged: without `--apply` cascade still only
+prints or writes the JSON.
```bash
-cascade status deploy services --env prod
+cascade branch-protection --apply --repo my-org/my-app --branch main
```
-##### Flags
+With `--apply`, `--branch` is the real protection target rather than just a label on the
+guidance note. cascade resolves the target repository from `--repo`, falling back to the
+`GITHUB_REPOSITORY` environment variable, and the REST API base from `--api-url`, falling
+back to `GITHUB_API_URL` and then `https://api.github.com`. A missing token or repository
+is reported before any network call, and a non-2xx response from GitHub (for example a
+`403` from an under-scoped token) is surfaced with GitHub's own rejection message.
-| Flag | Type | Default | Description |
-|------|------|---------|-------------|
-| `--env` | string | - | Environment name (required) |
+Applying branch protection requires a token with repo-admin authority (the
+`Administration: write` permission). The workflow `GITHUB_TOKEN` does not carry that
+authority, so `--apply` needs a scoped personal access token supplied through `--token` or
+the `GITHUB_TOKEN` environment variable. Prefer the environment variable over the flag so
+the token stays out of process arguments and shell history.
-#### status consistency
+```bash
+GITHUB_TOKEN=ghp_your_admin_pat \
+ cascade branch-protection --apply --repo my-org/my-app --branch main
+```
-Flag `env/*` integration branches that have no matching divergence in the manifest. A hotfix creates an `env/` branch that exists only while the environment is diverged; when the environment rejoins trunk the branch is deleted. A leftover `env/` branch with no diverged environment behind it is an orphan from an interrupted hotfix or manual branch creation.
+You can also pass `--output` alongside `--apply` to keep the emitted JSON on disk for your
+records; cascade writes the file first and then applies.
-By default the command only reports. With `--fix` it deletes each flagged orphan branch on the remote (default `origin`). A branch that backs a diverged environment is never touched, and deleting an already-absent branch is a no-op, so `--fix` is safe to re-run.
+#### What ends up required, and why it is safe
-```bash
-cascade status consistency
-cascade status consistency --fix
-```
+The required status checks contain only the cascade-controlled `Setup` and `Finalize` jobs.
+These are the orchestrate workflow's two steps jobs; cascade knows their exact check-run
+names and both run on every pipeline run. Because of that, `.protection` applied as-is never
+creates a required check that can never report, so it never blocks a pull request on its
+own.
+
+The reusable-workflow caller jobs (validate, build, deploy) are deliberately left out of
+the required contexts. cascade knows each caller's display-name prefix (for example
+`Build (my-app)`) but not the inner job name that GitHub appends to form the real check-run
+context, which is ` / `. That inner job lives in your reusable
+workflow, which cascade does not author. Requiring a bare prefix would never match and would
+block every pull request, so cascade lists those prefixes under
+`operator_todo.complete_these_contexts` as ` / ` placeholders
+instead. Replace `` with the job name inside each reusable workflow, then add the
+completed strings to `required_status_checks.contexts` when you want them required.
+
+In the default emit mode the `--branch` flag only labels the guidance note (with `--apply`
+it is the real apply target, as described above). Either way the required contexts are the
+same across branches and environments because they are the orchestrate-workflow steps jobs,
+so `--env` would not change them and is not offered.
+
+This command complements the hotfix branch-protection advisory (see
+[Run a hotfix](/cascade/guides/hotfix/)): the advisory prints ready-to-run `gh` commands for
+env branches, while `branch-protection` emits the full PUT body for the trunk.
-##### Flags
+#### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--fix` | bool | false | Delete the flagged orphan `env/*` branches on the remote |
-| `--remote` | string | `origin` | Remote to inspect and, with `--fix`, delete orphan branches on |
+| `--config`, `-c` | string | auto-detect | Path to manifest file |
+| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
+| `--branch` | string | `main` | Branch the protection targets; with `--apply` this is the real apply target, otherwise it labels the guidance note only and does not change the required contexts |
+| `--output`, `-o` | string | stdout | Write to this path instead of stdout (`-` also means stdout); honored alongside `--apply` to keep the JSON for your records |
+| `--apply` | bool | `false` | PUT the `.protection` body to GitHub instead of emitting JSON (requires a repo-admin token) |
+| `--token` | string | `GITHUB_TOKEN` | Scoped repo-admin token used for `--apply`; falls back to the `GITHUB_TOKEN` environment variable |
+| `--repo` | string | `GITHUB_REPOSITORY` | `owner/repo` the apply targets; falls back to the `GITHUB_REPOSITORY` environment variable |
+| `--api-url` | string | `GITHUB_API_URL` | REST API base for `--apply`; falls back to `GITHUB_API_URL`, then `https://api.github.com` |
-### graph
+### reconcile
-Render the manifest's generated pipeline as a Mermaid diagram on stdout. GitHub renders Mermaid natively in Markdown, so the output pastes directly into a README or pull request. `graph` is read-only: it never writes files, runs git, or modifies the repository. A missing or invalid manifest is reported as an error.
+Adopt an external governed action-pin change (for example a Dependabot bump landing in a
+generated workflow) back into the manifest's `action_pins`, then regenerate every workflow
+the manifest produces so cascade's owned output agrees with it again. `reconcile` never
+pushes, commits, or merges; wiring a CI job (or running it by hand) to drive it, and to
+commit and push its result, stays the caller's job.
```bash
-cascade graph
-cascade graph --granularity env
-cascade graph --granularity stages > pipeline.mmd
+cascade reconcile --changed-file .github/workflows/orchestrate.yaml
```
-The `--granularity` flag chooses the projection:
+`reconcile` reads the files named by `--changed-file` (repeatable) as data, scanning each
+line by line for a governed `uses:` reference, plus the manifest itself. It never reads a
+pin back out of a file the manifest generates: every generated file is exclusively a
+regenerate target, so a run that touches nothing relevant is a safe no-op and generation
+stays a pure offline function of the manifest. When a changed file carries a bump for an
+action cascade governs, `reconcile` writes that ref verbatim into the manifest's
+`action_pins`, keyed by action path, and regenerates. See the
+[Manifest reference](/cascade/reference/manifest/) for what that write looks like under
+`pin_mode: tag` and `pin_mode: sha`.
-- `jobs` renders the full job dependency graph, with hard dependencies as solid arrows and optional ordering-only ones as dotted arrows.
-- `stages` renders the coarse lifecycle flow from trunk through build, deploy, and promote to release.
-- `env` renders the promotion state machine, including any hotfix divergence and rejoin.
-- `cross-repo` renders the multi-repo flow, a lane per repository with the primary coordinating its dependent satellites and any satellite-to-primary notify edge.
+`reconcile` has three modes, selected by flag:
+
+| Mode | Flag | Behavior |
+|------|------|----------|
+| Default | (none) | Reconciles a user repo's manifest: adopts the bump into `action_pins` and regenerates. |
+| Detector | `--check` | Read-only: reports whether a governed pin changed and writes a data-only JSON artifact (`--check-output`) naming the changed refs; writes nothing else. |
+| Own-repo | `--own-repo` | Reconciles cascade's own `action_pins.yaml` manifest (a full re-marshal, since cascade owns that file) rather than a user manifest, and regenerates. |
+
+`--check` and `--own-repo` are mutually exclusive: the detector is read-only, and own-repo
+mode is a second write target, so combining them is rejected.
#### Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
-| `--config`, `-c` | string | auto-detect | Path to manifest file (default: auto-detect `.github/manifest.yaml`) |
+| `--config`, `-c` | string | `/.github/manifest.yaml` | Path to the manifest file |
| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
-| `--granularity` | string | `jobs` | Pipeline projection to render: `jobs`, `stages`, `env`, or `cross-repo` |
-| `--format` | string | `mermaid` | Diagram output format (supported value: `mermaid`) |
-| `--theme` | string | `cascade` | Diagram theme: `cascade`, `bland`, or a path to a JSON theme file |
+| `--root` | string | `.` | Repository root `reconcile` scans and writes relative to |
+| `--changed-file` | string (repeatable) | - | A changed source file to scan for a governed pin bump |
+| `--check` | bool | false | Read-only detector mode: report relevance and write a JSON artifact |
+| `--check-output` | string | `pin-reconcile-result.json` | Path to write the check-mode JSON artifact |
+| `--own-repo` | bool | false | Reconcile cascade's own `action_pins.yaml` manifest |
+| `--action-pins` | string | - | Path to `action_pins.yaml` (own-repo mode) |
+
+### reset
-## Environment Variables
+Reset releases and state for testing.
+
+```bash
+cascade reset --state --push
+```
+
+| Flag | Type | Default | Description |
+|------|------|---------|-------------|
+| `--state` | bool | false | Reset the state section in the manifest |
+| `--dry-run` | bool | false | Preview without executing |
+| `--push` | bool | false | Push state changes (requires `--state`) |
+| `--repo` | string | cwd | Path to repository |
+| `--config` | string | auto-detect | Path to manifest file |
+| `--manifest-key` | string | `ci` | Top-level key |
+
+Deletes all GitHub releases and tags. With `--state`, also clears the state section.
+
+## Environment variables
+
+cascade reads the following environment variables. It reads no `LOG_LEVEL` (verbosity is
+controlled by the `--trace` and `--json` flags) and no `GITHUB_RUN_ID` (promote finalize
+takes `--run-id` as an explicit flag).
| Variable | Description |
|----------|-------------|
-| `GITHUB_TOKEN` | GitHub API token for releases |
-| `GITHUB_ACTOR` | Default actor for commits/promotions |
-| `GITHUB_RUN_ID` | Workflow run ID (used by promote finalize) |
-| `LOG_LEVEL` | Logging verbosity (info, debug, trace) |
-| `NO_COLOR` | Disable colored output |
+| `GITHUB_TOKEN` | GitHub API token for releases and other API calls |
+| `GITHUB_ACTOR` | Default actor for commits and promotions |
+| `GITHUB_REPOSITORY` | `owner/repo`, used as a fallback target for API-calling commands |
+| `GITHUB_API_URL` | REST API base URL; falls back to `https://api.github.com` |
+| `GITHUB_SERVER_URL` | GitHub server base URL (for example on GitHub Enterprise) |
+| `GH_TOKEN` | Alternative token honored where a GitHub token is expected |
+| `RELEASE_TOKEN` | Token used for release and state-write operations that need elevated scope |
+| `NO_COLOR` | Disable colored output when set |
-## Exit Codes
+## Exit codes
+
+cascade uses three process exit codes.
| Code | Meaning |
|------|---------|
-| 0 | Success |
-| 1 | General error |
+| `0` | Success |
+| `1` | General error (the default for any failure) |
+| `2` | Operational failure returned by `verify` |
+
+Code `2` is owned by `verify`: it returns an error that carries an explicit exit code so
+callers can distinguish an operational failure (a missing or invalid manifest) from drift,
+which exits `1`. Every other command that fails returns the default exit code `1`. This
+matches the exit-code table in the [`verify`](#verify) section above.
-## JSON Output
+## JSON output
Pass `--json` (or use `--gha-output` inside Actions) to get machine-readable output:
@@ -1065,6 +1269,7 @@ Pass `--json` (or use `--gha-output` inside Actions) to get machine-readable out
id: detect
run: |
cascade detect-changes \
+ --config .github/manifest.yaml \
--base-sha "${{ github.event.before }}" \
--head-sha "${{ github.sha }}" \
--gha-output
@@ -1072,11 +1277,21 @@ Pass `--json` (or use `--gha-output` inside Actions) to get machine-readable out
## Debugging
+Raise log verbosity with the global `--trace` flag:
+
```bash
-cascade --trace parse-config
+cascade --trace parse-config --config .github/manifest.yaml
```
Trace logs include:
+
- File matching details
- Dependency resolution steps
-- API request/response info
+- API request and response info
+
+## Wayfinding
+
+- **Prerequisite**: [Getting started](/cascade/start/getting-started/). Install cascade
+ and generate your first pipeline.
+- **Next**: [Manifest reference](/cascade/reference/manifest/). Every manifest field the
+ commands above read.
diff --git a/docs/src/content/docs/reference/generated-workflows.md b/docs/src/content/docs/reference/generated-workflows.md
new file mode 100644
index 00000000..ed5be5f2
--- /dev/null
+++ b/docs/src/content/docs/reference/generated-workflows.md
@@ -0,0 +1,190 @@
+---
+title: Generated workflows
+description: The exact file set generate-workflow emits and the anatomy of each generated workflow.
+---
+
+`cascade generate-workflow` compiles your manifest into GitHub Actions workflows and one composite action. This page is the structural reference: the exact files, their triggers, jobs, and outputs. For "run this, watch that" operator recipes, see the [promote](/cascade/guides/promote/), [hotfix](/cascade/guides/hotfix/), and [rollback](/cascade/guides/rollback/) guides.
+
+## The generated file set
+
+Every run of `generate-workflow` against an environment pipeline emits these files unconditionally:
+
+| File | Emitted when | Purpose |
+|------|--------------|---------|
+| `.github/workflows/orchestrate.yaml` | always | CI/CD on trunk merges: build and deploy the first environment, cut the next rc. |
+| `.github/workflows/promote.yaml` | always | Manual promotion between environments, including publish at the release boundary. |
+| `.github/workflows/cascade-hotfix.yaml` | 2 or more environments configured | Roll a trunk fix onto a diverged environment. |
+| `.github/workflows/cascade-rollback.yaml` | 2 or more environments configured | Re-deploy a prior version or SHA to a promoted environment. |
+| `.github/actions/manage-release/action.yaml` | always | Composite action wrapping GitHub release create, update, lock, prerelease, publish, and delete. |
+
+`orchestrate.yaml` and `promote.yaml` are two separate files by design, generated together in one run; `--orchestrate-only` or `--promote-only` limits a run to one of them. Their output paths are configurable (`--output`, `--promote-output`); `cascade-hotfix.yaml` and `cascade-rollback.yaml` are always written to those fixed paths. The action folder name defaults to `manage-release` and is configurable via `--action-folder`.
+
+A single-environment project never gets `cascade-hotfix.yaml` or `cascade-rollback.yaml`: the first environment tracks trunk directly, so there is nothing to hotfix or roll back onto.
+
+Any claim that `generate-workflow` emits only an orchestrate and a promote workflow is out of date. Two more workflows and a composite action are unconditional, and the opt-in companions below add more on top depending on your manifest.
+
+## Orchestrate workflow anatomy
+
+Orchestrate fires on every merge to the trunk branch and runs the first environment's full pipeline.
+
+```mermaid
+flowchart TD
+ M["Merge to trunk"] --> S["Setup"] --> V["Validate"] --> B["Build"] --> D["Deploy"] --> F["Finalize"]
+ S -.-> sn["Parse manifest, detect changes, compute version"]
+ V -.-> vn["Optional pre-build validation"]
+ B -.-> bn["Matrix: triggered builds only"]
+ D -.-> dn["Matrix: triggered deploys, dependency-ordered"]
+ F -.-> fn["Update state, generate changelog, draft pre-release"]
+
+ classDef note fill:none,stroke:none,color:#8A929C;
+ class sn,vn,bn,dn,fn note;
+```
+
+The trigger is written directly from `config.trunk_branch` (default `main`):
+
+```yaml
+on:
+ push:
+ branches: [main]
+```
+
+Orchestrate takes no manual inputs; it runs automatically on push. Its outputs:
+
+| Output | Description |
+|--------|-------------|
+| `deployed_sha` | Deployed commit SHA. |
+| `triggered_builds` / `triggered_deploys` | JSON arrays of what ran. |
+| `version` | Calculated rc version, for example `v1.2.0-rc.0`. |
+| `changelog` | Generated changelog markdown. |
+| `release_url` | URL to the GitHub release. |
+| `execution_plan` | JSON execution plan with dependency-ordered waves. |
+
+The setup job reads the manifest's recorded SHA, diffs it against the current head, matches changed files against each callback's triggers, and builds an execution plan that respects `depends_on`. Version is computed from conventional commits since the last release: `feat!:`/`BREAKING CHANGE:` bumps major, `feat:` bumps minor, `fix:`/`perf:` bumps patch. The first environment always gets an rc suffix (`v1.2.0-rc.0`); each further orchestrate run increments the rc counter.
+
+## Promote workflow anatomy
+
+Promote is a manual (`workflow_dispatch`) workflow that walks the environment chain.
+
+```mermaid
+flowchart TD
+ M["Default mode (one step at a time)"] --> P["Preflight"] --> D["Deploy"] --> Pub["Publish"] --> F["Finalize"]
+ P -.-> pn["Validate source/target, check ancestry, gate breaking changes"]
+ D -.-> dn["Matrix: per-deploy with change detection"]
+ Pub -.-> pubn["Only at prerelease-to-release boundary, if publish: configured"]
+ F -.-> fn["Update state, publish release, dispatch Release workflow"]
+
+ classDef note fill:none,stroke:none,color:#8A929C;
+ class pn,dn,pubn,fn note;
+```
+
+Inputs: `mode` (`default` or a cascade target such as `dev-to-prod`), `force`, `allow_breaking_changes`, `dry_run`, `deploys`, `rollback_on_failure`. See the [promote guide](/cascade/guides/promote/) for what each one does operationally.
+
+### The deploy strategy block
+
+Each `deploy-` job carries a `strategy:` block. `promote.go:writeDeployStrategyOptions` writes it from the deploy's `rollout` config:
+
+```yaml
+jobs:
+ deploy-app:
+ strategy:
+ fail-fast: false # rollout.fail_fast (default false when unset)
+ max-parallel: 3 # rollout.max_parallel, only when > 0
+ matrix:
+ environment: ${{ fromJson(needs.preflight.outputs.deploy_app_matrix) }}
+```
+
+Only `fail_fast` and `max_parallel` reach this block. `rollout.type`, `rollout.canary`, and `rollout.blue_green` parse and validate but are reserved: they carry zero generator consumption today. See [Progressive rollout](/cascade/reference/versioning/#progressive-rollout) for the full reserved-field list.
+
+### Publish
+
+When the manifest has a `publish:` callback, promote adds a publish step that runs once per build at the prerelease-to-release boundary. It reads `artifact_id` from the source environment's build state and dispatches the publish workflow with `build_name`, `old_version`, `new_version`, `sha`, and `artifact_id`; the callback performs the registry operation (retag, copy, sign).
+
+## Hotfix and rollback workflows
+
+Both carry two triggers in one file and both mirror the promote deploy shape. Full operational detail lives in the [hotfix](/cascade/guides/hotfix/) and [rollback](/cascade/guides/rollback/) guides; this is the job-level anatomy.
+
+### `cascade-hotfix.yaml`
+
+| Job | Trigger | Role |
+|-----|---------|------|
+| plan | `workflow_dispatch` | Fetch env branches and tags, run `cascade hotfix plan`, surface branch-protection suggestions. |
+| apply | `workflow_dispatch` (not dry-run) | Cherry-pick onto each environment bottom-up; opens a resolution pull request. |
+| check | `pull_request` opened against `env/*` | Validate the manifest while the hotfix pull request is open. |
+| build | merged hotfix pull request | Build the merge SHA (a cherry-pick has no prebuilt artifact). |
+| deploy | merged hotfix pull request | Deploy to the target environment, paired with a rollback job mirroring promote. |
+| finalize | all deploys succeed | Run `cascade hotfix finalize`: write the diverged state, tag, and release. |
+
+Dispatch inputs: `commit` (one or more trunk fix SHAs), `target_env` (every configured environment except the first), `pr_number` (optional, to replay an existing resolution pull request), `dry_run`. The second trigger is `pull_request` on `types: [closed]` against `branches: ['env/*']`, gated on the pull request having merged with the `cascade-hotfix` label.
+
+### `cascade-rollback.yaml`
+
+| Job | Role |
+|-----|------|
+| preflight | Read-only: resolves the target version or SHA (defaults to N-1). |
+| deploy | Re-runs the configured deploy callbacks keyed on the resolved SHA. |
+| finalize | Writes the rolled-back state to trunk, marking the environment diverged. |
+
+Baseline trigger is `workflow_dispatch` only, with inputs `environment`, `target`, `deployable`, `dry_run`. Setting `rollback.repository_dispatch` in the manifest adds a `repository_dispatch` trigger alongside it; every parameter read then coalesces `github.event.inputs.*` with `github.event.client_payload.*` so both paths resolve the same target. See [the rollback guide](/cascade/guides/rollback/#the-rollback-manifest-block) and the [manifest reference](/cascade/reference/manifest/) for the block's fields.
+
+## The manage-release composite action
+
+`.github/actions/manage-release/action.yaml` wraps GitHub release operations behind one composite action so orchestrate, promote, hotfix, and rollback all call the same code path.
+
+| Input | Purpose |
+|-------|---------|
+| `action` | `create`, `update`, `lock`, `prerelease`, `publish`, or `delete`. |
+| `repo`, `sha`, `tag`, `environment` | Identify the release target. |
+| `changelog` | Release notes markdown. |
+| `previous_tag`, `new_tag`, `delete_tag`, `create_tag` | Used by specific actions (changelog comparison, retagging, cleanup). |
+| `token` | A GitHub token with repo permissions. |
+
+Outputs: `release_id`, `release_url`, `html_url`. The action shells out to the same `cascade` binary already installed by `setup-cli`, so its behavior matches the CLI exactly.
+
+## Opt-in companions
+
+These emit only when their manifest block is present and enabled; an unconfigured manifest is unaffected.
+
+| File | Enabled by | Purpose |
+|------|------------|---------|
+| `cascade-pr-preview.yaml` | `pr_preview.enabled: true` | Read-only PR plan preview; no deploys. |
+| `cascade-drift-check.yaml` | `drift_check.enabled: true` | Fails a PR check when generated output has drifted from the manifest. |
+| `cascade-drift-comment.yaml` | `drift_check.enabled: true` and `drift_check.comment: true` | Fork-safe companion that posts the drift result as a sticky PR comment. |
+| `cascade-reconcile-check.yaml` | `reconcile.enabled: true` | Read-only detector for an external governed action-pin change. |
+| `cascade-reconcile-companion.yaml` | `reconcile.enabled: true` | Adopts the detected pin change back into the manifest. |
+| `cascade-validate.yaml` | `validate_check.enabled: true` | Runs manifest validation as its own PR check. |
+| `cascade-merge-queue.yaml` | `merge_queue.enabled: true` | Adds a merge-queue validation lane. |
+| `external-update.yaml` | manifest declares `external` repos | Accepts satellite deploy notifications into the primary's state; see the [multi-repo guide](/cascade/guides/multi-repo/). |
+
+## How concurrency, timeouts, and permissions appear in output
+
+**Concurrency.** Every cascade-owned workflow gets a top-level `concurrency:` block, but the default `cancel-in-progress` value differs by workflow because the risk differs:
+
+| Workflow | Default group | Default `cancel-in-progress` | Why |
+|----------|---------------|-------------------------------|-----|
+| Orchestrate | `orchestrate-${{ github.ref }}` | `true` | A newer push obsoletes an older in-flight build. |
+| Promote | `${{ github.workflow }}` | `false` | Every run pushes the same manifest state and tags; queue rather than abandon a mid-flight write. |
+| Hotfix | per-environment (dispatch) or per-repo (finalize) | `false` (fixed, not configurable) | Concurrent finalize runs on different environments must not race the same trunk push. |
+| Rollback | `${{ github.workflow }}` | `false` | Same reasoning as promote. |
+
+Set `concurrency.group` and `concurrency.cancel_in_progress` in the manifest to override the orchestrate, promote, and rollback defaults; hotfix's grouping is fixed.
+
+**Timeouts.** Cascade-owned jobs (setup, finalize, preflight, and similar) get `timeout-minutes: 30` unless `job_timeout_minutes` is set in the manifest. This never applies to your own callback jobs, since GitHub forbids `timeout-minutes` on a job that calls a reusable workflow with `uses:`; set your own timeout inside the called workflow.
+
+**Permissions.** Each callback job gets a job-level `permissions:` block scoped to only what that callback declared, including `id-token: write` when the callback needs OIDC:
+
+```yaml
+jobs:
+ deploy-app:
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/deploy-app.yaml
+```
+
+This is least-privilege by construction: the top-level workflow permissions stay read-only, and write scopes (`contents: write` for state pushes, `actions: write` to dispatch Release) live only on the jobs that need them.
+
+## Wayfinding
+
+**Prerequisite:** [How Cascade works](/cascade/start/how-it-works/) for the mental model these workflows implement.
+
+**Next:** [Manifest reference](/cascade/reference/manifest/) for every field that shapes this output.
diff --git a/docs/src/content/docs/reference/manifest.md b/docs/src/content/docs/reference/manifest.md
new file mode 100644
index 00000000..b74cd061
--- /dev/null
+++ b/docs/src/content/docs/reference/manifest.md
@@ -0,0 +1,819 @@
+---
+title: Manifest reference
+description: Every field in the cascade manifest, grouped by lifecycle, with each field's emission status stated.
+---
+
+The manifest is the single input cascade compiles into GitHub Actions workflows. This page documents every field, grouped by the order you meet them, from the fields nearly everyone sets down to the reserved and validated-only tails almost nobody touches.
+
+Each field carries an emission status:
+
+| Status | Meaning |
+|--------|---------|
+| **emitted** | Changes generated workflow output. |
+| **validated-only** | Parsed and schema-checked, but never appears in generated YAML. |
+| **reserved** | Parsed, but has no generator consumption today. |
+
+## File shape and schema support
+
+A manifest holds both pipeline configuration and managed deployment state under a top-level wrapper key (`ci:` by default):
+
+```yaml
+ci:
+ config: # Pipeline definition (you write this)
+ schema_version: 1
+ trunk_branch: main
+ environments: [dev, test, prod]
+ # builds, deploys, and the rest below
+
+ state: # Deployment tracking (managed by cascade; do not edit)
+ dev:
+ sha: "abc123"
+ version: "v1.2.0-rc.3"
+
+ latest_release: # Most recent published release (managed)
+ version: "v1.1.0"
+ sha: "abc000"
+```
+
+The wrapper key is set by `manifest_key` and the file path by `manifest_file`.
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `schema_version` | emitted | int | `1` when omitted | Manifest schema generation. Omitting it emits a CLI warning; set `schema_version: 1` in every manifest. |
+| `manifest_file` | emitted | string | `.github/manifest.yaml` | Path to the manifest file. |
+| `manifest_key` | emitted | string | `ci` | Top-level wrapper key inside the file. |
+| `action_folder` | emitted | string | `manage-release` | Folder name for the generated manage-release action. |
+
+Every field below lives under `ci.config` unless stated otherwise. The `ci.state` block is covered in [State section](#state-section-managed).
+
+### Editor support
+
+cascade ships a hand-authored JSON Schema. Registering it gives autocomplete, type checking, enum hints, and hover docs while you author the manifest. The schema covers structure, types, and enums; `cascade parse-config` remains the authority for semantic and cross-field rules.
+
+The schema is published at:
+
+```
+https://stablekernel.github.io/cascade/manifest.schema.json
+```
+
+Print the embedded copy with `cascade schema` (write it to a file with `cascade schema --output manifest.schema.json`).
+
+Add this directive to the top of the manifest so the YAML language server (VS Code, Neovim, and others) picks it up automatically:
+
+```yaml
+# yaml-language-server: $schema=https://stablekernel.github.io/cascade/manifest.schema.json
+ci:
+ config:
+ schema_version: 1
+ trunk_branch: main
+```
+
+Or map the schema to your manifest path in VS Code `settings.json`:
+
+```json
+{
+ "yaml.schemas": {
+ "https://stablekernel.github.io/cascade/manifest.schema.json": ".github/manifest.yaml"
+ }
+}
+```
+
+## Top-level identity
+
+The two fields that define the pipeline shape.
+
+| Field | Status | Type | Required | Default | Description |
+|-------|--------|------|----------|---------|-------------|
+| `trunk_branch` | emitted | string | Yes | `main` | The trunk branch releases flow from. |
+| `environments` | emitted | list | No | - | The promotion chain. Omit for a no-environment library or CLI project. |
+
+```yaml
+ci:
+ config:
+ schema_version: 1
+ trunk_branch: main
+ environments: [dev, test, prod]
+ cli_version: v0.9.1
+```
+
+:::note[Environment names are yours; roles are positional]
+The `environments` list is fully configurable. cascade attaches no meaning to specific labels: `dev`, `test`, `staging`, and `prod` are illustrative, not reserved. Roles are decided by position, not by name. The last environment is the release stage, the second-to-last is the prerelease environment, and the publish boundary is the final crossing into the last environment. The count is structural too: zero environments is release-only, one environment generates a single-environment Release workflow, and two or more enable the full promote cascade.
+
+**Naming.** Environment, build, and deploy names become GitHub Actions job IDs and output keys, so keep them identifier-safe: letters, digits, and underscores (hyphens read as subtraction in GitHub Actions expressions). The generator-owned names `environment` and `dry_run` cannot be used as `dispatch_inputs`.
+:::
+
+### Trigger configuration
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `triggers` | emitted | list | - | Global path patterns that activate orchestration. See [Trigger patterns](#trigger-patterns). |
+| `release_trigger` | emitted | string | `push` | How orchestrate fires. `push` keeps push-on-trunk plus `workflow_dispatch`; `dispatch` drops `push:` so releases run only on manual `workflow_dispatch`. |
+| `tag_prefix` | emitted | string | `v` | Version tag prefix. |
+
+Workflow-level trigger types beyond `push` are set under [`extra_triggers`](#extra_triggers).
+
+## CLI pinning
+
+These fields pin the cascade CLI and third-party actions the generated workflows install.
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `cli_version` | emitted | string | latest | cascade CLI version the generated workflows install via setup-cli. Use `latest`, a prerelease channel, or a specific `vX.Y.Z`. |
+| `cli_version_sha` | emitted | string | - | 40-hex commit SHA that `cli_version` resolves to. Under `pin_mode: sha`, the generated setup-cli ref pins to this commit. |
+| `pin_mode` | emitted | string | `tag` | Third-party action pin policy: `tag` emits `@`; `sha` emits `@` with the version as a trailing comment. |
+| `action_pins` | emitted | map | - | Per-action ref overrides keyed by action path (for example `actions/checkout`), applied regardless of `pin_mode`. |
+
+### cli_version values
+
+| Value | Behavior |
+|-------|----------|
+| `latest` | Most recent stable release (default). |
+| `beta` | Newest prerelease build. |
+| `vX.Y.Z` | A specific version (for example `v0.9.1`). Pin for reproducibility. |
+
+### cli_version_sha
+
+Under `pin_mode: sha`, pair `cli_version` with `cli_version_sha`, the 40-character lowercase-hex commit the `cli_version` tag resolves to. The generated setup-cli ref then pins to that immutable commit, with `cli_version` carried as a trailing comment:
+
+```yaml
+uses: stablekernel/cascade/.github/actions/setup-cli@9dc69a1f66753a3865c38c34eca5a931f677c803 # v0.9.1
+```
+
+The `with: version:` input the action reads to select the release asset stays the human-readable tag, so only the action source is pinned to a commit. The field is optional and takes effect only under `pin_mode: sha`. Because cascade release tags are annotated, resolve the underlying commit (not the tag object) with `git ls-remote https://github.com/stablekernel/cascade 'refs/tags/^{}'`.
+
+### Action pinning
+
+Generated workflows are build output. cascade owns the third-party action pins inside them (for example `actions/checkout` and `actions/github-script`) and reconciles that ownership back to the manifest. The supported way to change a pinned action is `pin_mode` and `action_pins`, not a hand-edit of the generated YAML. A hand-edited pin is reported as drift by the next `cascade verify` and overwritten by the next regenerate.
+
+`pin_mode` sets the reference style for every third-party action cascade emits:
+
+| Value | Behavior |
+|-------|----------|
+| `tag` | Default. Emits `@` (for example `actions/checkout@v4`). Never `@latest`. |
+| `sha` | Emits `@ # `, pinning each action to an immutable commit with the human-readable version as a trailing comment. Under `sha`, pair `cli_version` with [`cli_version_sha`](#cli_version_sha) so the cascade self-action ref is pinned too. |
+
+The `sha` values come from a single committed pin table (`internal/generate/action_pins.yaml`); no per-repo configuration is needed beyond setting `pin_mode: sha`.
+
+`action_pins` overrides the built-in ref for individual actions, keyed by action path. The value is the bare ref emitted after `@` for that same action (a tag or a commit SHA); it cannot repoint an action to a different owner or repository. An override applies regardless of `pin_mode`:
+
+```yaml
+ci:
+ config:
+ pin_mode: sha
+ action_pins:
+ actions/checkout: 0123456789abcdef0123456789abcdef01234567
+```
+
+That emits `uses: actions/checkout@0123456789abcdef0123456789abcdef01234567`. An action neither in the built-in table nor overridden is emitted unchanged. `action_pins` is also the write target for [`cascade reconcile`](/cascade/reference/cli/#reconcile), which adopts an external governed-pin change (for example a Dependabot bump) into the manifest.
+
+## Tokens and authentication
+
+Two seams call GitHub on cascade's behalf: `release_token` for release API calls and the rc tag, and `state_token` for writing manifest state to trunk. Both default to `${{ secrets.GITHUB_TOKEN }}`, which is enough for a single-repo project whose trunk is unprotected.
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `release_token` | emitted | string | `state_token` if set, else `${{ secrets.GITHUB_TOKEN }}` | Token expression for release API calls and the rc tag. Inherits `state_token` when unset so the rc-to-release chain has a trigger-capable token. |
+| `state_token` | emitted | string | `${{ secrets.GITHUB_TOKEN }}` | Token expression for writing manifest state to the trunk branch. |
+| `release_token_app` | emitted | object | - | GitHub App identity that mints a release token at run time (`app_id`, `private_key`). |
+| `state_token_app` | emitted | object | - | GitHub App identity that mints a state-write token at run time (`app_id`, `private_key`). |
+
+Reference secrets by bare name; cascade wraps a bare name in a `${{ secrets.* }}` expression for you:
+
+```yaml
+ci:
+ config:
+ release_token: RELEASE_PAT
+ state_token: STATE_PAT
+```
+
+:::caution[`release_token` must be trigger-capable]
+The release token creates the rc tag, and that tag triggers the Release run, fleet validation, and promotion. GitHub suppresses workflow triggers for ref creations made with the default `GITHUB_TOKEN`, so an rc tag created with it fires nothing. An unset `release_token` inherits your `state_token` when one is set. Whatever resolves as the release token must be trigger-capable (a PAT or a GitHub App token). If your state token is supplied solely through `state_token_app`, set a static `release_token` explicitly, since a minted App token is a run-time step output the default cannot reach.
+:::
+
+A GitHub App avoids storing a long-lived PAT. Point `release_token_app` and `state_token_app` at App secrets; cascade mints a fresh, short-lived installation token per run via `actions/create-github-app-token`, guarded to real GitHub with `if: ${{ github.server_url == 'https://github.com' }}`:
+
+```yaml
+ci:
+ config:
+ release_token_app:
+ app_id: CASCADE_APP_ID
+ private_key: CASCADE_APP_PRIVATE_KEY
+ state_token_app:
+ app_id: CASCADE_APP_ID
+ private_key: CASCADE_APP_PRIVATE_KEY
+```
+
+Add the App to the repository ruleset bypass list so it can write a protected trunk. Store only the private key as a secret. On act or gitea the minting step is skipped (the `github.server_url` guard does not match) and consumers fall back to the static `release_token` / `state_token`, so set both an App source and a static token if you run the same manifest locally and against real GitHub.
+
+## git
+
+Optional git identity and signing configuration for state commits.
+
+```yaml
+ci:
+ config:
+ git:
+ mode: custom
+ user_name: deploy-bot
+ user_email: bot@example.com
+ gpg_key_id: GPG_KEY_ID
+ gpg_key_secret: GPG_PRIVATE_KEY
+```
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `mode` | emitted | string | `default` | `default`, `custom`, or `external`. |
+| `user_name` | emitted | string | github-actions[bot] | Git `user.name` (when `mode: custom`). |
+| `user_email` | emitted | string | github-actions[bot]@users.noreply.github.com | Git `user.email`. |
+| `gpg_key_id` | emitted | string | - | Secret name holding the GPG key ID. |
+| `gpg_key_secret` | emitted | string | - | Secret name holding the GPG private key. |
+
+`default` uses the `github-actions[bot]` identity, `custom` uses your supplied name and email, and `external` skips git config entirely (the runner is assumed pre-configured). When both `gpg_key_id` and `gpg_key_secret` are set, cascade imports the key, enables `commit.gpgsign`, and signs state commits.
+
+## validate
+
+Optional pre-build validation callback.
+
+```yaml
+ci:
+ config:
+ validate:
+ workflow: .github/workflows/validate.yaml
+ supports_dry_run: false
+ triggers: [src/**]
+ inputs:
+ check_lint: true
+ env_inputs:
+ prod:
+ check_security: true
+ run_policy: default
+ on_failure: abort
+ retries: 0
+```
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `workflow` | emitted | string | - | Path to the validation workflow. |
+| `supports_dry_run` | emitted | bool | false | Whether the callback handles the `dry_run` input. |
+| `triggers` | emitted | list | - | File patterns that trigger validation. |
+| `inputs` | emitted | map | {} | Static inputs passed to the workflow. |
+| `env_inputs` | emitted | map | {} | Per-environment input overrides. |
+| `run_policy` | emitted | string | `default` | Execution policy. See [Policy fields](#policy-fields). |
+| `on_failure` | emitted | string | `abort` | Failure handling. See [Policy fields](#policy-fields). |
+| `retries` | emitted | int | 0 | Retry attempts (0-3). |
+
+## builds
+
+Builds produce artifacts (container images, binaries, and the like). `builds` is a list.
+
+```yaml
+ci:
+ config:
+ builds:
+ - name: app
+ workflow: .github/workflows/build-app.yaml
+ triggers: [src/**, Dockerfile]
+ depends_on: []
+ inputs:
+ dockerfile: ./Dockerfile
+ env_inputs:
+ prod:
+ sign_image: true
+ permissions:
+ contents: read
+ id-token: write
+ matrix:
+ dimensions:
+ os: [linux, darwin]
+ arch: [amd64, arm64]
+ max_parallel: 4
+ fail_fast: false
+ state_tags: [image_tag]
+ auto_commits: false
+ run_policy: default
+ on_failure: abort
+ retries: 0
+```
+
+| Field | Status | Type | Required | Description |
+|-------|--------|------|----------|-------------|
+| `name` | emitted | string | Yes | Unique build identifier. |
+| `workflow` | emitted | string | Yes | Path to the build workflow. |
+| `triggers` | emitted | list | No | Glob patterns that trigger this build. |
+| `depends_on` | emitted | list | No | Other callbacks to wait for (hard dependency). |
+| `optional_depends_on` | emitted (ordering) | list | No | Soft dependency: orders this job after the named jobs when they exist, without failing generation if a name is absent. The counterpart to `depends_on`. |
+| `inputs` | emitted | map | No | Static inputs to the workflow. |
+| `env_inputs` | emitted | map | No | Per-environment input overrides. |
+| `permissions` | emitted | map | No | Job-level `permissions:` for this callback's caller job. See [Permissions](#permissions). |
+| `matrix` | emitted | object | No | Build fan-out. See [matrix](#matrix). |
+| `state_tags` | emitted (behavior) | list | No | State-capture tags recorded for this build (the field behind the callback contract's State Capture). |
+| `auto_commits` | emitted (behavior) | bool | No | When true, cascade captures the HEAD sha the callback advanced to (runtime env `AUTO_COMMITS_HEAD_SHA`), for callbacks that commit during their run. |
+| `run_policy` | emitted | string | No | Execution policy. See [Policy fields](#policy-fields). |
+| `on_failure` | emitted | string | No | Failure handling. See [Policy fields](#policy-fields). |
+| `retries` | emitted | int | No | Retry attempts (0-3). |
+| `runs_on` | validated-only | object | No | Parsed and validated, never emitted. See [Validated-only fields](#validated-only-fields). |
+| `concurrency` | validated-only | object | No | Parsed and validated, never emitted. See [Validated-only fields](#validated-only-fields). |
+| `timeout_minutes` | validated-only | int | No | Parsed and validated, never emitted. See [Validated-only fields](#validated-only-fields). |
+
+The build's `artifact_id` output (if declared) is captured into state automatically. Other declared outputs are forwarded to dependent deploys as inputs.
+
+### matrix
+
+`matrix` (builds only) fans a build across a cross-product of dimensions.
+
+| Sub-field | Status | Type | Description |
+|-----------|--------|------|-------------|
+| `dimensions` | emitted | map | The cross-product axes (for example `os: [linux, darwin]`, `arch: [amd64, arm64]`). |
+| `max_parallel` | emitted | int | Caps concurrent matrix legs (0 uses the GitHub Actions default). |
+| `fail_fast` | emitted | bool | Whether a failing leg cancels the rest. Unset applies the GitHub Actions default (true for matrix builds). |
+
+### Permissions
+
+A `permissions` map renders as a job-level `permissions:` block on the caller job that invokes the callback, scoping the `GITHUB_TOKEN` to least privilege for that one job. GitHub Actions treats a job-level block as the **complete** permission set: it replaces the workflow default rather than merging. Declare the full set the callback needs, including `contents: read` if it checks out code and `id-token: write` for OIDC. cascade emits exactly the scopes you declare and never injects an implicit one.
+
+```yaml
+permissions:
+ contents: read
+ id-token: write
+```
+
+This is the shipped OIDC answer: a per-callback `permissions:` block carrying `id-token: write` grants that one job an OIDC token without widening any other job.
+
+## deploys
+
+Deploys target environments. `deploys` is a list and shares most fields with `builds`.
+
+```yaml
+ci:
+ config:
+ deploys:
+ - name: infra
+ workflow: .github/workflows/deploy-infra.yaml
+ triggers: [cdk/**]
+ supports_dry_run: true
+ depends_on: []
+ permissions:
+ contents: read
+ id-token: write
+ rollout:
+ max_parallel: 2
+ fail_fast: false
+ run_policy: default
+ on_failure: abort
+ retries: 0
+```
+
+| Field | Status | Type | Required | Description |
+|-------|--------|------|----------|-------------|
+| `name` | emitted | string | Yes | Unique deploy identifier. |
+| `workflow` | emitted | string | Yes | Path to the deploy workflow. |
+| `triggers` | emitted | list | No | Glob patterns that trigger this deploy. |
+| `depends_on` | emitted | list | No | Other callbacks to wait for. |
+| `optional_depends_on` | emitted (ordering) | list | No | Soft dependency, as for builds. |
+| `supports_dry_run` | emitted | bool | No | Whether the callback handles `dry_run`. |
+| `inputs` | emitted | map | No | Static inputs. |
+| `env_inputs` | emitted | map | No | Per-environment overrides. |
+| `permissions` | emitted | map | No | Job-level `permissions:` for the caller job. See [Permissions](#permissions). |
+| `rollout` | partial | object | No | Deploy rollout strategy. See [rollout](#rollout). |
+| `state_tags` | emitted (behavior) | list | No | State-capture tags recorded for this deploy. |
+| `auto_commits` | emitted (behavior) | bool | No | Captures the advanced HEAD sha, as for builds. |
+| `run_policy` | emitted | string | No | Execution policy. |
+| `on_failure` | emitted | string | No | Failure handling. |
+| `retries` | emitted | int | No | Retry attempts (0-3). |
+| `runs_on` | validated-only | object | No | Parsed and validated, never emitted. |
+| `concurrency` | validated-only | object | No | Parsed and validated, never emitted. |
+| `timeout_minutes` | validated-only | int | No | Parsed and validated, never emitted. |
+
+### Deploy types
+
+Deploys are classified by their configuration:
+
+| Type | Configuration | When it runs |
+|------|--------------|--------------|
+| Trigger-based | Has `triggers` | When matching files change. |
+| Build-linked | Has `depends_on` referencing a build | When the referenced build runs. |
+| Unconstrained | No `triggers` or `depends_on` | Always runs. |
+
+Build-linked deploys inherit the build's triggers for change detection during promotions.
+
+### rollout
+
+`rollout` (deploys only) tunes the deploy job's `strategy:` block. Its status is **partial**: two sub-fields are emitted, the rest are reserved.
+
+| Sub-field | Status | Type | Description |
+|-----------|--------|------|-------------|
+| `max_parallel` | emitted | int | Caps concurrent rollout waves. Emitted into the deploy job's `strategy:` block. |
+| `fail_fast` | emitted | bool | Whether a failing wave cancels the rest. Emitted into the `strategy:` block. Unset differs from an explicit false. |
+| `type` | reserved | string | `default`, `rolling`, `canary`, or `blue_green`. Parsed, not yet wired to generation. |
+| `canary` | reserved | object | Canary sub-block (`steps`, `analysis`, `percent`, `bake_time`, `promote_callback`, `rollback_callback`). Inert today. |
+| `blue_green` | reserved | object | Blue/green sub-block. Inert today. |
+
+See [Versioning and schema](/cascade/reference/versioning/) for the full reserved rollout shape.
+
+## publish
+
+The publish callback runs once per build when a release is published, at the point where an rc version becomes a final semver. Use it to retag artifacts that still carry their rc version.
+
+```yaml
+ci:
+ config:
+ publish:
+ workflow: .github/workflows/publish.yaml
+```
+
+| Field | Status | Type | Required | Description |
+|-------|--------|------|----------|-------------|
+| `workflow` | emitted | string | Yes | Path to the publish workflow (reusable, `workflow_call` trigger). |
+
+`publish` also accepts `permissions`, `rollout`, `state_tags`, and `auto_commits` with the same semantics as a deploy, and `runs_on` / `concurrency` / `timeout_minutes` as validated-only. The callback is invoked once per configured build and receives `build_name`, `old_version` (the rc version in the registry), `new_version` (the final semver), `sha`, and `artifact_id` (the immutable digest from the build's `artifact_id` output). cascade carries metadata only; the publish workflow performs the registry operation.
+
+## external and notify
+
+`external` (primary repos) and `notify` (satellite repos) coordinate deployments across repositories. A repository cannot set both.
+
+### external
+
+`external` is designed for satellite-repo artifact coordination: a satellite owns its own build, deploys to its first environment, then notifies the primary, which records the satellite's SHA and version in the shared manifest and includes its deploys in every subsequent promotion. It is not a GitOps mirror.
+
+```yaml
+ci:
+ config:
+ external:
+ - repo: org/cdk-infra
+ ref: main
+ deploys:
+ - name: cdk
+ workflow: .github/workflows/deploy-cdk.yaml
+ triggers: [cdk/**]
+ on_update:
+ deploy:
+ workflow: org/cdk-infra/.github/workflows/deploy.yaml
+```
+
+| Field | Status | Type | Required | Description |
+|-------|--------|------|----------|-------------|
+| `repo` | emitted | string | Yes | External repository (for example `org/cdk-infra`). |
+| `ref` | emitted | string | No | Branch or tag reference (default: `trunk_branch`). |
+| `deploys` | emitted | list | Yes | Deployables from this repo. |
+| `deploys[].name` | emitted | string | Yes | Unique deploy identifier. |
+| `deploys[].workflow` | emitted | string | Yes | Workflow path (local, or `org/repo/.github/workflows/x.yaml@ref` for external). |
+| `deploys[].triggers` | emitted | list | No | File patterns for change detection. |
+| `deploys[].on_update.deploy.workflow` | emitted | string | No | Reusable workflow to run as a scoped deploy when this slot is recorded. |
+
+By default the receiver is record-only. Setting `on_update.deploy.workflow` opts a component into a scoped deploy that runs synchronously in the same receiver run, right after the slot is recorded and only if the record step succeeds. Inline `run:` and `shell:` are not supported. See [Coordinate multiple repos](/cascade/guides/multi-repo/) for the operator recipe.
+
+### notify
+
+For satellite repos that report deployments back to a primary.
+
+```yaml
+ci:
+ config:
+ notify:
+ repo: org/my-backend
+ workflow: external-update.yaml
+ token: PRIMARY_REPO_TOKEN
+ deploy_name: artifact-a
+ environment: staging
+```
+
+| Field | Status | Type | Required | Default | Description |
+|-------|--------|------|----------|---------|-------------|
+| `repo` | emitted | string | Yes | - | Primary repository to notify. |
+| `workflow` | emitted | string | No | `external-update.yaml` | Workflow name. |
+| `token` | emitted | string | No | `PRIMARY_REPO_TOKEN` | Secret name for cross-repo dispatch. |
+| `deploy_name` | emitted | string | No | first local deploy, then first build | Deploy name to dispatch, when the primary recognizes this satellite under a different name. |
+| `environment` | emitted | string | No | first local environment, then `dev` | Environment to dispatch, when the primary expects a different one. |
+
+The primary validates the dispatched `deploy_name` and `environment` against its own config, so a satellite whose local names differ from what the primary expects must send the parent-recognized values.
+
+## release and changelog
+
+```yaml
+ci:
+ config:
+ release:
+ disabled: false
+ workflow: .github/workflows/release-assets.yaml
+ changelog:
+ disabled: false
+ contributors: true
+```
+
+### release
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `disabled` | emitted | bool | false | Disable cascade release management. |
+| `tag` | emitted | string | - | `callback.output` reference for an external release tool. |
+| `workflow` | emitted | string | - | Release workflow dispatched against a release tag to build and attach binaries. |
+| `version_overrides` | reserved | object | - | Reserved pointer (`dir:`) to maintainer-committed version-intent override files. See [Versioning](/cascade/reference/versioning/). |
+
+When `workflow` is set, cascade dispatches it (via `gh workflow run --ref `) rather than relying only on the tag-push trigger, which GitHub does not reliably start when the tagged commit carries a CI-skip marker. Omit the section to use cascade defaults.
+
+### changelog
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `disabled` | emitted | bool | false | Disable changelog generation entirely. |
+| `workflow` | emitted | string | - | Path to a custom changelog workflow. |
+| `contributors` | emitted | bool | false | Include contributor attribution via the GitHub API. |
+
+Omit the section to use the built-in conventional commit parser.
+
+## environment_config
+
+Per-environment settings keyed by environment name. Consumed for native GitHub Environment support and deployment URLs.
+
+```yaml
+ci:
+ config:
+ environments: [production]
+ environment_config:
+ production:
+ gha_environment: production
+ environment_url: "https://app.example.com"
+ required_reviewers: [octocat]
+ wait_timer: 10
+ branch_policy: protected
+```
+
+| Sub-field | Status | Description |
+|-----------|--------|-------------|
+| `gha_environment` | emitted | Maps the cascade environment to a real GitHub Environment (native deployments, `environment_url`). |
+| `environment_url` | emitted | URL reported on the Deployment status for that environment. |
+| `required_reviewers` | emitted (via `environments` command) | Reviewers the `environments` command applies to the GitHub Environment. |
+| `wait_timer` | emitted (via `environments` command) | Wait timer the `environments` command applies. |
+| `branch_policy` | emitted (via `environments` command) | Branch policy the `environments` command applies. |
+
+GitHub Environment support is shipped: `gha_environment` is consumed for native deployments, and the `cascade environments` command emits `required_reviewers`, `wait_timer`, and `branch_policy` for an operator to apply. See [Add or change environments](/cascade/guides/environments/).
+
+## Workflow-level fields
+
+Fields that shape the cascade-owned workflows as a whole rather than a single callback.
+
+### concurrency
+
+Top-level concurrency block emitted onto the orchestrate, promote, hotfix, rollback, release, and external-update workflows.
+
+```yaml
+ci:
+ config:
+ concurrency:
+ group: cascade-${{ github.ref }}
+ cancel_in_progress: false
+```
+
+| Sub-field | Status | Type | Description |
+|-----------|--------|------|-------------|
+| `group` | emitted | string | The concurrency group expression. |
+| `cancel_in_progress` | emitted | bool | Whether a new run cancels an in-progress run in the same group. |
+
+### job_timeout_minutes
+
+```yaml
+ci:
+ config:
+ job_timeout_minutes: 30
+```
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `job_timeout_minutes` | emitted | int | 30 | Sets `timeout-minutes` on cascade-owned jobs. |
+
+### extra_triggers
+
+Non-push trigger types wired onto the generated workflows.
+
+```yaml
+ci:
+ config:
+ extra_triggers:
+ schedule:
+ - cron: "0 7 * * *"
+ repository_dispatch:
+ types: [deploy-request]
+ merge_group: {}
+```
+
+| Sub-field | Status | Description |
+|-----------|--------|-------------|
+| `schedule` | emitted | List of cron schedule entries. Each entry has one required key, `cron`. |
+| `repository_dispatch` | emitted | Wires the `repository_dispatch` trigger; `types` lists the event types. |
+| `workflow_run` | emitted | Wires the `workflow_run` trigger. |
+| `merge_group` | emitted | Present (even empty) wires the merge-queue trigger. The validation lane behavior lives in the separate `merge_queue` block. |
+
+### rollback
+
+Opts the rollback workflow into a `repository_dispatch` trigger, driving the rollback-dispatch path.
+
+```yaml
+ci:
+ config:
+ rollback:
+ repository_dispatch:
+ types: [rollback-request]
+```
+
+| Sub-field | Status | Description |
+|-----------|--------|-------------|
+| `repository_dispatch` | emitted | Reuses the shared `RepositoryDispatchTrigger` shape (`types`), configured the same way as `extra_triggers.repository_dispatch`. |
+
+See [Roll back an environment](/cascade/guides/rollback/) for the operator recipe.
+
+## Companion workflows (opt-in)
+
+Each of these emits an additional workflow only when its block is present. Omit the block and output is unchanged.
+
+### pr_preview
+
+```yaml
+ci:
+ config:
+ pr_preview:
+ enabled: true
+ comment: true
+```
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `enabled` | emitted | bool | false | Emit the PR-preview companion. |
+| `comment` | emitted | bool | false | Also post or update a sticky preview comment. |
+
+### drift_check
+
+Emits a pull-request workflow that runs [`cascade verify`](/cascade/reference/cli/#verify) and fails the check when committed workflows fall out of sync with the manifest.
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `enabled` | emitted | bool | false | Emit `.github/workflows/cascade-drift-check.yaml` (read-only, `contents: read`). |
+| `comment` | emitted | bool | false | Also emit the fork-safe comment companion (`cascade-drift-comment.yaml`). |
+
+The check job triggers on `pull_request` and is read-only; the comment companion triggers on `workflow_run` in the base-repo context with a scoped `pull-requests: write` token and derives the target PR only from trusted run metadata. When you set `comment: true`, consider `pin_mode: sha` to remove the floating-tag exposure on the one write-scoped job.
+
+### reconcile
+
+Emits the fork-safe [`cascade reconcile`](/cascade/reference/cli/#reconcile) lane that adopts an external governed-pin change into `action_pins` and regenerates.
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `enabled` | emitted | bool | false | Emit the reconcile detector and companion workflows. |
+| `source` | emitted | string | `dependabot` | The change-source adapter the companion recognizes. |
+| `commit` | emitted | string | `append` | Adoption commit routing. `append` pushes onto the triggering PR branch; `followup` opens a separate PR (prefer this if you automerge on green). |
+
+### deployments
+
+Reports deployment status through the GitHub Deployments API from the finalize job.
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `enabled` | emitted | bool | false | Create a Deployment and report status. Adds `deployments: write` to top-level permissions only when enabled. |
+| `keep_prior_active` | emitted | bool | false | Set `auto_inactive: false` so GitHub leaves prior deployments Active. |
+
+Every Deployments API step carries an `if: ${{ github.server_url == 'https://github.com' }}` guard, so on act or gitea the steps are skipped. Pair with `environment_config..environment_url` so the status links to the running environment.
+
+### validate_check
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `enabled` | emitted | bool | false | Emit `.github/workflows/cascade-validate.yaml`, a `pull_request` check that runs `cascade parse-config` and fails on an invalid manifest. |
+
+The check validates cascade's own configuration only, requests `contents: read` alone, and has no dry-run or comment side effects.
+
+### merge_queue
+
+| Field | Status | Type | Default | Description |
+|-------|--------|------|---------|-------------|
+| `enabled` | emitted | bool | false | Emit `.github/workflows/cascade-merge-queue.yaml`, a `merge_group`-triggered lane that runs `cascade parse-config` and a dry-run `cascade orchestrate setup` against the merge-group candidate. |
+
+The lane is read-only. This block owns the lane behavior; the raw `merge_group` trigger is expressible separately under `extra_triggers.merge_group`, and the two are intentionally distinct.
+
+## Shared policy and pattern reference
+
+### Policy fields
+
+`run_policy`, `on_failure`, and `retries` apply to `validate`, each `builds` entry, and each `deploys` entry.
+
+| `run_policy` | Behavior |
+|--------------|----------|
+| `default` | Skip if any dependency was skipped. |
+| `always` | Run if triggered, even if dependencies skipped. |
+| `force` | Always run, ignore triggers and dependencies. |
+
+| `on_failure` | Behavior |
+|--------------|----------|
+| `abort` | Fail the entire workflow. |
+| `continue` | Let other callbacks proceed. |
+
+`retries` is the number of retry attempts on failure (0-3).
+
+### Trigger patterns
+
+Triggers use glob patterns:
+
+| Pattern | Matches |
+|---------|---------|
+| `src/**` | All files under `src/` recursively. |
+| `*.go` | Go files in the root directory. |
+| `**/*.yaml` | YAML files anywhere in the repo. |
+| `Dockerfile` | Exact file match. |
+| `cdk/*.ts` | TypeScript files directly in `cdk/` (not recursive). |
+
+`*` matches any characters except `/`, `**` matches any path segments, and `?` matches a single character.
+
+### Input inheritance
+
+Inputs flow from static `inputs` to per-environment `env_inputs`, with the environment-specific value winning:
+
+```yaml
+deploys:
+ - name: services
+ inputs:
+ cluster: default-cluster
+ region: us-east-1
+ env_inputs:
+ dev:
+ cluster: dev-cluster
+ prod:
+ region: us-west-2
+```
+
+For `dev`: `{ cluster: "dev-cluster", region: "us-east-1" }`. For `prod`: `{ cluster: "default-cluster", region: "us-west-2" }`.
+
+## Validated-only fields
+
+These fields parse and pass schema validation but **never appear in generated YAML**. They are kept in the schema so a manifest that sets them stays valid and forward-compatible, but they change nothing today.
+
+| Field | Where | Why it is not emitted |
+|-------|-------|-----------------------|
+| `runs_on` | top-level and per-callback | GitHub Actions forbids `runs-on` on a reusable-workflow `uses:` caller job, so cascade-owned jobs are hardcoded `ubuntu-latest`. Per-environment runner overrides are blocked by the same structural limit. |
+| `concurrency` (per-callback) | `builds[]`, `deploys[]`, `publish` | GitHub Actions forbids a `concurrency:` block on a reusable-workflow caller job. Top-level `concurrency` is emitted; the per-callback form is not. |
+| `timeout_minutes` (per-callback) | `builds[]`, `deploys[]`, `publish` | The timeout belongs inside the called workflow, not the caller job. Use top-level `job_timeout_minutes` to bound the cascade-owned jobs. |
+
+## Reserved fields
+
+Reserved fields parse but have zero generator consumption today. They reserve a stable shape so a future capability can land without a schema break.
+
+| Field | Where | Note |
+|-------|-------|------|
+| `telemetry` | top-level | `enabled`, `adapter`, plus reserved `webhook` and `job_summary`. No generator consumption. |
+| `rollout.type` / `rollout.canary` / `rollout.blue_green` | `deploys[]` | Reserved rollout sub-blocks (`rollout.fail_fast` and `rollout.max_parallel` are emitted; the rest are inert). |
+| `release.version_overrides` | `release` | Reserved pointer to version-intent override files. |
+| `deploy_target` | `deploys[]` | Reserved shape for the GitOps mirror pattern. |
+
+The full reserved-shapes catalog, including per-component versioning and the canary sub-fields (`steps`, `analysis`, `percent`, `bake_time`, `promote_callback`, `rollback_callback`), lives in [Versioning and schema](/cascade/reference/versioning/).
+
+## State section (managed)
+
+The `ci.state` block tracks deployment state per environment plus a synthetic `release` slot. cascade manages it automatically; do not hand-edit.
+
+```yaml
+ci:
+ state:
+ dev:
+ sha: "abc123def456"
+ version: "v1.2.0-rc.3"
+ committed_at: "2026-01-15T10:30:00Z"
+ committed_by: "github-actions[bot]"
+ builds:
+ app:
+ sha: "abc123def456"
+ artifact_id: "sha256:def456..."
+ tags:
+ image_tag: "abc123-1736923500"
+ deploys:
+ infra:
+ sha: "abc123def456"
+ deployed_at: "2026-01-15T10:30:00Z"
+ release:
+ sha: "def789abc012"
+ version: "v1.1.0"
+ latest_release:
+ version: "v1.1.0"
+ sha: "def789abc012"
+```
+
+| Environment-level field | Description |
+|-------------------------|-------------|
+| `sha` | Commit SHA promoted into this environment. |
+| `version` | Semantic version tag (for example `v1.2.3-rc.0`). |
+| `committed_at` / `committed_by` | ISO 8601 timestamp and actor for the promotion. |
+| `builds` / `deploys` / `external` | Per-callback tracking (auto-populated). |
+
+The implicit `release` slot tracks the most recently published (non-draft) GitHub release. Promotions to the last environment cross the `release` boundary first, where the breaking-change gate runs and the publish callback fires. Per-build state carries `artifact_id` (the canonical identifier passed to publish) and `tags` (the build's other declared outputs). Per-deploy state enables diff-based change detection, so only deployables with actual file changes are redeployed.
+
+## Validation rules
+
+`cascade parse-config` enforces the semantic rules the schema alone cannot:
+
+- `schema_version` should be `1`. Omitting it emits a warning.
+- Environment, build, and deploy names must be identifier-safe (letters, digits, underscores). The generator-owned names `environment` and `dry_run` are reserved and cannot be used as `dispatch_inputs`.
+- `pin_mode` must be `tag` or `sha`; `run_policy` must be `default`, `always`, or `force`; `on_failure` must be `abort` or `continue`; `retries` must be 0-3.
+- A repository cannot set both `external` (primary) and `notify` (satellite).
+- A per-callback `permissions` block is the complete permission set for that caller job and replaces the workflow default rather than merging.
+- `cli_version_sha` takes effect only under `pin_mode: sha`.
+
+## What to read next
+
+- **Prerequisite**: [Getting started](/cascade/start/getting-started/) walks you from install to a first pipeline.
+- **Next**: [Callback contract](/cascade/reference/callbacks/) documents the inputs and outputs the workflows referenced by `validate`, `builds`, `deploys`, and `publish` must honor.
diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/reference/versioning.md
similarity index 52%
rename from docs/src/content/docs/versioning.md
rename to docs/src/content/docs/reference/versioning.md
index e705517c..88165f44 100644
--- a/docs/src/content/docs/versioning.md
+++ b/docs/src/content/docs/reference/versioning.md
@@ -1,11 +1,11 @@
---
title: Versioning and schema compatibility
-description: How the cascade manifest schema is versioned and how the CLI decides whether it can read a given manifest, including compatibility rules and the hotfix version segment.
+description: How the cascade manifest schema is versioned, which reserved fields are inert, and the hotfix version grammar.
---
-The cascade manifest is the contract between your repository and the cascade CLI. This document describes how the manifest schema is versioned and how the CLI decides whether it can read a given manifest.
+The cascade manifest is the contract between your repository and the cascade CLI. This page describes how the manifest schema is versioned, which fields are reserved for future behavior, and how version numbers are allocated during a hotfix.
-## `schema_version`
+## Schema policy
Every manifest may declare a schema version under `ci.config`:
@@ -17,17 +17,9 @@ ci:
# ...
```
-`schema_version` is a single monotonic integer, the "schema major". It is not a semver string. It identifies which breaking-change generation of the schema the manifest is written for.
+`schema_version` is a single monotonic integer, the "schema major." It is not a semver string. It identifies which breaking-change generation of the schema the manifest is written for. The current version is `1`.
-### Why an integer
-
-The manifest evolves additively. New capabilities arrive as new optional fields, new enum values, or new nested blocks, each with a sensible default. An older CLI ignores fields it does not recognize, and a newer CLI fills in defaults for fields an older manifest omits. Because of this, additive changes never change `schema_version`. The integer only moves when a change is genuinely breaking:
-
-- a field is removed,
-- a field is re-typed,
-- the default behavior of an existing field changes.
-
-A semver string would imply minor and patch schema axes that, given the additive-only design, never need to exist.
+The manifest evolves additively: new capabilities arrive as new optional fields, new enum values, or new nested blocks, each with a sensible default. An older CLI ignores fields it does not recognize, and a newer CLI fills in defaults for fields an older manifest omits. Additive changes never bump `schema_version`. The integer only moves when a change is genuinely breaking: a field is removed, a field is re-typed, or the default behavior of an existing field changes.
## Compatibility rules
@@ -36,20 +28,20 @@ The CLI knows two bounds:
- `CurrentSchemaVersion` is the highest schema version this CLI understands. A manifest that omits `schema_version` is assumed to target this version.
- `MinSchemaVersion` is the oldest schema version this CLI still reads.
-On load, the CLI applies the following rules:
+On load, the CLI applies these rules:
| Manifest `schema_version` | CLI behavior |
| --- | --- |
| equal to `CurrentSchemaVersion` | Accepted silently. |
-| omitted or `0` | Accepted with a warning; assumed to be `CurrentSchemaVersion`. Pin it explicitly. Because `schema_version` is an `int` field with `omitempty`, an explicit `schema_version: 0` is encoded identically to an absent field and is treated the same way, as omitted. |
+| omitted or `0` | Accepted with a warning; assumed to be `CurrentSchemaVersion`. Pin it explicitly. Because `schema_version` is an `int` field with `omitempty`, an explicit `schema_version: 0` is encoded identically to an absent field and treated the same way. |
| between `MinSchemaVersion` and `CurrentSchemaVersion - 1` | Accepted with a warning; the CLI still reads it. See the migration table below. |
-| below `MinSchemaVersion` (and not `0`) | Rejected. The schema generation is no longer supported; follow the migration table. |
-| above `CurrentSchemaVersion` | Rejected. The manifest needs a newer CLI; upgrade the `cli_version` pin. A newer schema may rely on changed semantics this CLI would mis-handle, so it does not guess. |
+| below `MinSchemaVersion` (and not `0`) | Rejected. Follow the migration table. |
+| above `CurrentSchemaVersion` | Rejected. The manifest needs a newer CLI; upgrade the `cli_version` pin. |
| negative | Rejected as invalid. |
-A rejected manifest is a fatal, generation-blocking condition: the CLI reports the error and does not produce workflows. A warning is non-fatal and is surfaced on stderr and in the `warnings` field of `parse-config` JSON output.
+A rejected manifest is fatal: the CLI reports the error and produces no workflows. A warning is non-fatal and surfaces on stderr and in the `warnings` field of `parse-config` JSON output.
-## Schema-version to CLI-version matrix
+### Schema-version to CLI-version matrix
| Schema version | First CLI version | Status |
| --- | --- | --- |
@@ -57,70 +49,74 @@ A rejected manifest is a fatal, generation-blocking condition: the CLI reports t
This table is updated whenever `schema_version` is bumped.
-## Deprecation window
+### Deprecation window
A CLI supports the current schema version and the immediately preceding one (N-1). When a new schema major lands, CLIs that ship with it continue to read the previous major with a warning. A subsequent major may drop support for the oldest major, at which point manifests at that version are rejected with a pointer to the migration entry in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md).
-## Reserved shape: per-component versioning
+## Reserved shapes
+
+These fields parse and pass structural validation today but carry no generator, state, or runtime behavior. A manifest declaring them produces byte-identical generated workflows, so adopting the shape now is safe. Attaching behavior to any of them later is additive and does not bump `schema_version`.
-The manifest reserves the shape for independently versioned components that share one manifest. Three slots are frozen at `schema_version` 1:
+### Per-component versioning
+
+Three slots are frozen at `schema_version` 1 for independently versioned components that share one manifest:
- A top-level `components` map, keyed by component name, where each entry carries an optional `path` (the subtree the component owns) and `tag_prefix` (its version-tag prefix).
- A matching `state..components` map that records the per-component version and SHA for an environment.
- A `latest_release.components` map that records the per-component published release.
-These slots parse and pass structural validation today, but carry no generator, state, or runtime behavior. A manifest may declare them without changing any generated workflow. Component names must be job-ID-safe (letters, digits, hyphens, underscores) and a configured `path` must be relative with no `..` segments, so a later release can attach behavior without re-typing the fields.
-
-That later release attaches behavior additively, so it does not bump `schema_version`: a manifest written against the reserved shape stays valid, and the schema-version-to-CLI matrix above is unchanged.
+Component names must be job-ID-safe (letters, digits, hyphens, underscores), and a configured `path` must be relative with no `..` segments.
-## Reserved shape: progressive rollout
+### Progressive rollout: canary and blue/green
-The manifest reserves the shape for progressive rollout on a deploy callback. A deploy may declare a `rollout:` block with a `type` of `default`, `rolling`, `canary`, or `blue_green`, and an optional sub-block matching that type.
+A deploy may declare a `rollout:` block with a `type` of `default`, `rolling`, `canary`, or `blue_green`. Two fields on `rollout:` are live (see [Progressive rollout](#progressive-rollout) below); the type-specific sub-blocks are reserved.
-The `canary:` sub-block reserves four fields:
+The `canary:` sub-block reserves six fields:
- `percent`, the initial canary weight, an integer from 1 to 100.
- `bake_time`, the soak duration before promotion, written as a Go duration string (for example `30m`).
- `promote_callback`, a local workflow path that performs the promotion.
- `rollback_callback`, a local workflow path that performs the rollback.
+- `steps`, the percent waves for a multi-step rollout (for example `[10, 50, 100]`).
+- `analysis`, a workflow path that gates each wave.
The `blue_green:` sub-block reserves one field:
- `switch`, the workflow path that performs the cutover.
-These fields parse and pass structural validation today, but carry no generator behavior. A manifest declaring them produces byte-identical generated workflows, so the reserved shape is safe to adopt now. Attaching behavior to these fields later is additive and does not bump `schema_version`.
-
`matrix:` and `rollout:` are separate canonical concerns: `matrix:` describes the fan-out a callback runs across, and `rollout:` describes how a release advances through a callback. There is no shared `strategy:` block that combines them.
-## Reserved shape: GitOps deploy target
+### GitOps deploy target
-The manifest reserves the shape for a GitOps-mirror deploy variant on a deploy. A deploy may declare a `deploy_target:` block with a `mode` of `dispatch` (the default, the existing external/notify cross-repo model) or `gitops` (push a rendered field into a dedicated config repo).
+A deploy may declare a `deploy_target:` block with a `mode` of `dispatch` (the default, the existing external/notify cross-repo model) or `gitops` (push a rendered field into a dedicated config repo). The `gitops` variant reserves:
-The `gitops` variant reserves these enriched fields:
-
-- `branch`, the target branch for the GitOps write (an env-to-branch mapping); the default is the target repo's default branch.
+- `branch`, the target branch for the GitOps write (an env-to-branch mapping); default is the target repo's default branch.
- `track_sha`, a boolean that, when true, records the post-push HEAD SHA of the target repo into state.
-A matching per-env deploy state slot, `target_sha`, reserves room to record the reconciled GitOps-repo HEAD SHA so a future implementation can key promotion off it.
+A matching per-env deploy state slot, `target_sha`, reserves room to record the reconciled GitOps-repo HEAD SHA so a future implementation can key promotion off it. `branch` and `track_sha` are meaningful only when `mode` is `gitops`.
+
+### Telemetry sink
+
+The manifest reserves a vendor-neutral telemetry seam under `config.telemetry`: `enabled`, and an `adapter` value (for example `none` or `datadog`) so no vendor client is baked into cascade. Two enriched fields:
-`branch` and `track_sha` are meaningful only when `mode` is `gitops`. These fields parse and pass structural validation today, but carry no generator behavior. A manifest declaring them produces byte-identical generated workflows, so the reserved shape is safe to adopt now. Attaching behavior to these fields later is additive and does not bump `schema_version`.
+- `webhook`, a generic JSON-POST sink with a `url` (the destination the run posts telemetry to) and a `secret_name` (the name of a GitHub Actions secret holding the auth token, never an inline token value).
+- `job_summary`, a boolean that toggles the run-UI summary table. It is omitted when unset, so an unset value stays distinct from an explicit `false`.
-## Reserved shape: telemetry sink
+See the full field list in the [manifest reference](/cascade/reference/manifest/).
-The manifest reserves a vendor-neutral telemetry seam under `config.telemetry`. The seam carries `enabled` and an `adapter` value (for example `none` or `datadog`); the vendor stays a value behind the adapter, so no vendor client is baked into cascade. The reserved shape adds two enriched fields:
+### Version-intent overrides
-- `webhook`, a generic JSON-POST sink with a `url` (the destination the run posts telemetry to) and a `secret_name` (the name of a GitHub Actions secret holding the auth token). `secret_name` is a reference to a secret, never an inline token value.
-- `job_summary`, a boolean that toggles the run-UI summary table. It is omitted when unset, so an unset value stays distinct from an explicit `false`; default-on behavior arrives in a later release.
+cascade derives the next version from conventional commits. Some version intent cannot be expressed that way, for example forcing a pre-release line or a specific exact version for a release. Under `release:`, a `version_overrides:` block addresses maintainer-committed override files carrying that intent:
-These fields parse and pass structural validation today, but carry no generator or emit behavior. A manifest declaring them produces byte-identical generated workflows, so the reserved shape is safe to adopt now. Attaching behavior to these fields later is additive and does not bump `schema_version`.
+- `dir`, a relative directory pointer to the override files. Must be relative with no `..` segments. Empty means the implementation default (reserved).
-## Reserved shape: version-intent overrides
+Only the addressing pointer is frozen in v1. The override-file format and the fold-into-version-calculation behavior are additive and arrive later; any future override values map onto the existing version primitives (the bump level and the pre-release line) rather than introducing a parallel scheme.
-cascade derives the next version from conventional commits. Some version intent cannot be expressed that way, for example forcing a pre-release line or a specific exact version for a release. The manifest reserves, under `release:`, a `version_overrides:` block that addresses maintainer-committed override files carrying that intent:
+## Progressive rollout
-- `dir`, a relative directory pointer to the override files. It must be a relative path with no `..` segments. Empty means the implementation default (reserved).
+Two fields on `rollout:` are live today, not reserved. `rollout.fail_fast` and `rollout.max_parallel` (deploys and publish only) are emitted directly into the deploy job's `strategy:` block: `fail_fast` sets `strategy.fail-fast` (defaulting to `false` when unset), and `max_parallel`, when greater than zero, sets `strategy.max-parallel`. A manifest that sets either field changes the generated workflow.
-Only the addressing pointer is frozen in v1. The override-file format and the fold-into-version-calculation behavior are additive and arrive post-1.0; any future override values map onto the existing version primitives (the bump level and the pre-release line) rather than introducing a parallel scheme. This block parses and passes structural validation today, but carries no generator, state, or runtime behavior. A manifest declaring it produces byte-identical generated workflows, so the reserved shape is safe to adopt now, and attaching behavior later does not bump `schema_version`.
+Only the `type`, `canary`, and `blue_green` sub-blocks remain reserved and inert, as described above. Setting `type: canary` or populating a `canary:`/`blue_green:` sub-block parses and validates but has no effect on generated output today.
## Migrations
@@ -130,21 +126,21 @@ Each `schema_version` bump is recorded with a `Migration` section in [CHANGELOG.
### 0.x (current)
-This is the active development line. Bug fixes, security patches, and new capabilities all land here. No stability guarantee is made for the CLI command surface or the manifest schema between 0.x releases. Additive changes arrive without a `schema_version` bump. Breaking changes (field removals, type changes, behaviour changes) increment `schema_version` and carry a `Migration` entry in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md).
+This is the active development line. Bug fixes, security patches, and new capabilities all land here. No stability guarantee is made for the CLI command surface or the manifest schema between 0.x releases. Additive changes arrive without a `schema_version` bump. Breaking changes (field removals, type changes, behavior changes) increment `schema_version` and carry a `Migration` entry in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md).
### 1.0
When cascade reaches v1.0 the following guarantees apply:
- The CLI command surface (flags, subcommands, exit codes, JSON output shapes) follows semver: breaking changes require a major version bump.
-- The manifest schema follows the integer-major versioning described in this document. An additive change never bumps `schema_version`; only a breaking change does.
-- The N-1 schema deprecation window (described above) is honoured across all 1.x releases.
+- The manifest schema follows the integer-major versioning described in this page. An additive change never bumps `schema_version`; only a breaking change does.
+- The N-1 schema deprecation window is honored across all 1.x releases.
Older tags outside the current release line do not receive backported fixes. See [SECURITY.md](https://github.com/stablekernel/cascade/blob/main/SECURITY.md) for the security-patch policy.
-## Hotfix version segment
+## Hotfix version grammar
-A hotfix applies one or more trunk commits onto an environment pinned to an older trunk base (see the Hotfix section of [Workflows](/cascade/workflows/)). The version cascade allocates for a hotfix depends on whether the environment's current version is still in flight (an rc) or already published.
+A hotfix applies one or more trunk commits onto an environment pinned to an older trunk base (see the [hotfix guide](/cascade/guides/hotfix/)). The version cascade allocates for a hotfix depends on whether the environment's current version is still in flight (an rc) or already published.
### rc-based (unpublished) base
@@ -165,7 +161,7 @@ A hotfix version therefore slots cleanly between its base rc and the next rc, an
### Published (no rc) base
-When the environment holds a published version with no rc segment (for example `v1.3.0`), a hotfix is a **normal patch bump**, not a `-hotfix.M` shape:
+When the environment holds a published version with no rc segment (for example `v1.3.0`), a hotfix is a normal patch bump, not a `-hotfix.M` shape:
```
v1.3.0 -> v1.3.1 (first hotfix)
@@ -180,6 +176,12 @@ cascade allocates the next free patch by reconciling against existing tags, so t
| --- | --- | --- |
| New optional manifest field with a sensible default | patch | none |
| New CLI subcommand or flag | minor | none |
-| Changed default behaviour of an existing field | major | bump |
+| Changed default behavior of an existing field | major | bump |
| Field removed or re-typed | major | bump |
| CLI flag or subcommand removed | major | none |
+
+## Wayfinding
+
+**Prerequisite:** the [manifest reference](/cascade/reference/manifest/) for the full field surface these reserved shapes and the `rollout:` block sit inside.
+
+**Next:** [Security](/cascade/security/) for the trust model, action pinning, and hardening checklist.
diff --git a/docs/src/content/docs/security.md b/docs/src/content/docs/security.md
new file mode 100644
index 00000000..f416a8e2
--- /dev/null
+++ b/docs/src/content/docs/security.md
@@ -0,0 +1,127 @@
+---
+title: Security and hardening
+description: Cascade's trust model, the shared-responsibility split between cascade and your organization, and a concrete checklist for hardening a generated pipeline.
+---
+
+Cascade generates GitHub Actions workflow definitions and coordinates promotion across environments. Those workflows are committed to, and run inside, your own repositories, under your own runners, branch protection, and environment gates. Security is shared: cascade emits sound, reviewable, least-privilege workflow definitions, and your organization configures the GitHub and cloud controls that decide what those workflows are allowed to do.
+
+This page is written for a security reviewer evaluating cascade before adoption, or auditing a pipeline already running it. It covers the trust model, what cascade ships secure by construction today, what remains your organization's job, and a checklist to work through.
+
+## Trust model
+
+Cascade is a build-time tool, not a runtime service. It reads your manifest and writes workflow YAML that you commit and review in your own repository. There is no cascade-operated service in the request path at run time, and no cascade-held credential: every workflow runs under your repository's runners, your `GITHUB_TOKEN`, and your organization's policies. If you deleted every cascade binary today, the workflows it already generated would keep running unchanged, because they are ordinary GitHub Actions YAML, not calls back to a cascade service.
+
+### Same-organization, shared-token model
+
+Cross-repo coordination (a satellite repository signaling its primary, or a primary dispatching to a satellite) uses a same-organization, shared-token model. One repository hands off to another by firing a `repository_dispatch` or `workflow_dispatch` call authenticated with a token you provision: typically a fine-grained personal access token or a GitHub App installation token, held as a repository or organization secret.
+
+That token is the trust boundary for cross-repo coordination. Any party that holds it can trigger the coordinated workflow in the target repository. Cascade does not add an additional identity or signature check on top of it today: possession of the token is authorization. Treat it as a production credential, scope it as narrowly as your GitHub App or PAT model allows, and rotate it on the same cadence as any other privileged credential. See [Coordinate multiple repos](/cascade/guides/multi-repo/) for how the token is wired into `external` and `notify` blocks.
+
+### Callback auth scope
+
+A callback (validate, build, deploy, publish) is a reusable workflow you author, invoked with `workflow_call` from a cascade-generated caller job. The callback runs with whatever `permissions:` and `secrets:` the caller job grants it, nothing more: cascade does not pass a blanket token or an implicit credential into your callback. What a callback can do to your cloud, registry, or deployment target is entirely a function of the `permissions:` block and secrets you wire onto it (see [Least privilege](#least-privilege-per-callback-permissions) below), not anything cascade injects.
+
+This means the callback's blast radius is bounded by two things you control: the manifest's `permissions:` entry for that callback, and the environment gate (if any) the callback's own workflow declares. Cascade surfaces the second requirement rather than hiding it: see [GitHub Environments as a gate](#github-environments-as-a-gate) below.
+
+## Shared responsibility
+
+### What cascade provides, secure by construction
+
+These properties hold for generated output today, unconditionally:
+
+- **Local reusable workflows are commit-pinned.** Workflows referenced as `./.github/...` are pinned to the calling commit, so your own callbacks resolve from a fixed commit rather than a moving branch.
+- **Every callback is a reusable workflow.** Validate, build, deploy, and publish callbacks run as reusable workflows referenced by `workflow:`. Cascade does not emit inline scripts on your behalf; the script your pipeline runs is code you author and review in a workflow file, not text generated from the manifest.
+- **The reusable-deploy gate boundary is surfaced at generate time.** GitHub does not allow a job-level `environment:` on a job that calls a reusable workflow. When you wire a reusable deploy, cascade warns you at generation time that the environment gate must live in the called workflow, so the requirement is explicit rather than silently dropped.
+- **Third-party actions are pinned through a single manifest.** See [Action pinning](#action-pinning) below.
+- **Callback permissions are least-privilege by default.** See [Least privilege](#least-privilege-per-callback-permissions) below.
+- **Generated workflows and commits are deterministic and reviewable.** Output is plain YAML committed to your repository. You can diff it, review it, and pin it before it ever runs.
+- **The artifact identifier is tracked end to end.** Cascade records an artifact digest in pipeline state alongside the human-readable version tag, and can resolve it back later.
+
+### What your organization must configure
+
+GitHub and your cloud own these controls; cascade cannot set them for you:
+
+- **Branch protection** on your trunk: require reviews and status checks, restrict who can push, and require signed commits where appropriate. The `branch-protection` command (see the [CLI reference](/cascade/reference/cli/)) emits the matching settings, or applies them for you with `--apply` given a repo-admin token (the workflow `GITHUB_TOKEN` cannot hold repo-admin scope).
+- **Tag protection or rulesets** so release and version tags cannot be moved or forged.
+- **Environment protection rules** with required reviewers, wait timers, and deployment branch or tag policies on production environments. For reusable deploys, this gate lives in the called workflow, not the calling job.
+- **CODEOWNERS on workflow files** (`.github/workflows/**`) so changes to the pipeline itself require owner review.
+- **Restricted Actions settings**: an allow-list of permitted actions and reusable workflows, fork-PR run approval, and a default `GITHUB_TOKEN` that is read-only.
+- **An OIDC trust policy** in your cloud, scoped to specific repository, environment, and ref, issuing short-lived role sessions instead of long-lived static credentials.
+- **Environment-scoped secrets** so production credentials are available only to the gated production job, not to every job in the repository.
+- **Scoped, short-lived tokens** for cross-repo coordination: prefer a GitHub App over a broad personal access token, and never replicate one long-lived token across many repositories.
+- **Artifact integrity controls** in your registry: immutable tags and registry RBAC so a published artifact cannot be swapped after it is produced.
+
+### What remains future work
+
+A short, honest list of things cascade does not yet do, so you do not assume they exist:
+
+- Built-in authenticated cross-repo coordination (identity or signature verification on the receiving end) beyond the shared dispatch token described above.
+- SHA-pinning as the default `pin_mode` (SHA pinning itself ships today; only the default value is still `tag`; see [Action pinning](#action-pinning)).
+- Deploying by immutable digest by default, rather than by version tag.
+- Provenance attestation on generated or built artifacts.
+
+## Action pinning
+
+Third-party action pinning is shipped today, not a roadmap item. Two mechanisms back it:
+
+- **`pin_mode`**, a manifest field with two values: `tag` (default) and `sha`. It controls how cascade renders every generated action reference. In `sha` mode, generated workflows pin third-party actions to a full commit SHA instead of a moving tag, closing the class of supply-chain risk where a tag is retargeted after review.
+- **The embedded `action_pins` manifest**, a map of action name to pinned ref, shipped inside cascade and overridable per-project. It is the single source cascade consults when rendering every `uses:` line for an action it manages, so a pin bump is a one-line manifest change rather than a search-and-replace across every generated workflow.
+
+```yaml
+ci:
+ config:
+ pin_mode: sha
+ action_pins:
+ actions/checkout: a1b2c3d4e5f6...
+```
+
+The only thing that remains future work is changing the *default* from `tag` to `sha`. If you want SHA pinning today, set `pin_mode: sha` in your manifest; do not wait for a default change, and do not describe SHA pinning to a reviewer as "planned."
+
+## Least privilege: per-callback permissions
+
+Cascade emits a job-level `permissions:` block scoped to each individual callback-calling job, driven by a `permissions:` map on that callback in the manifest. This is real, generated output today, not a design goal:
+
+```yaml
+builds:
+ - name: app
+ workflow: ./.github/workflows/build-app.yaml
+ permissions:
+ contents: read
+ id-token: write
+```
+
+Because the block is per-job, one callback's grant never leaks to another job in the same workflow. A build callback that only needs to read the repository and mint an OIDC token carries exactly `contents: read` and `id-token: write`, not the broader default permission set GitHub Actions grants a workflow that declares no `permissions:` block at all.
+
+Scoped `secrets:` follow the same shape: a callback's `secrets:` entry names exactly the secrets that callback receives, rather than the reusable-workflow default of inheriting every secret in scope. Combine the two and a compromised or misbehaving callback is bounded to the permissions and secrets its own manifest entry names, not the union of everything the pipeline touches.
+
+## OIDC
+
+The per-callback `permissions:` block is also how cascade wires OpenID Connect. Setting `id-token: write` on a callback's `permissions:` map is the standard GitHub Actions mechanism for letting that job request a short-lived OIDC token, which your cloud provider's trust policy exchanges for a scoped, time-limited role session. Because the grant is per-callback, only the callbacks that actually deploy or publish need to carry `id-token: write`; a validate or lint callback does not.
+
+Cascade does not configure the receiving side: the OIDC trust policy in AWS, GCP, or Azure that decides which repository, environment, and ref may exchange a token for a role is a control you own (see [Shared responsibility](#what-your-organization-must-configure) above). Scope that trust policy tightly; a `permissions: id-token: write` grant is only as safe as the trust policy on the other end of the exchange.
+
+## GitHub Environments as a gate
+
+GitHub Environments are the shipped answer for gating a deploy with required reviewers, wait timers, and branch or tag restrictions. `environment_config..gha_environment` on the manifest wires a cascade-managed environment (native GitHub deployments, `environment_url`) onto that environment's deploy jobs, and the `environments` command emits the matching per-environment settings (`required_reviewers`, `wait_timer`, `branch_policy`) for an operator to apply through the GitHub API or UI.
+
+The one structural caveat: GitHub does not allow a job-level `environment:` on a job that calls a reusable workflow. A reusable deploy's gate has to live inside the called workflow, and cascade warns at generation time when a manifest wires a reusable deploy without one. See [Add or change environments](/cascade/guides/environments/) for the full setup walkthrough.
+
+## Hardening checklist
+
+Work through this when standing up or reviewing a cascade pipeline. The order moves from the cross-repo trust boundary outward to the surrounding controls.
+
+1. **Secure the cross-repo dispatch token.** Store it as an environment-scoped secret, scope it to only the permissions the coordination needs, prefer a GitHub App or a short-lived token over a broad personal access token, and rotate it. Do not replicate one long-lived token across many repositories.
+2. **Protect trunk and tags, and add CODEOWNERS.** Require review on `main` and on `.github/workflows/**`, and protect release and version tags from being moved or deleted. Run `branch-protection --apply` with a repo-admin token, or apply the emitted JSON manually.
+3. **Turn on `pin_mode: sha`.** SHA pinning is shipped; it just is not the default. Set it explicitly if your threat model includes a retargeted third-party action tag.
+4. **Scope every callback's `permissions:` and `secrets:`.** Do not lean on reusable-workflow secret inheritance. List exactly the secrets and permission scopes each callback needs, and add `id-token: write` only to callbacks that actually deploy or publish.
+5. **Gate every production deploy with a GitHub Environment.** Set `environment_config..gha_environment`, run the `environments` command, and apply the emitted settings. For reusable deploys, place the `environment:` gate inside the called workflow.
+6. **Scope your cloud's OIDC trust policy narrowly.** Restrict it to the specific repository, environment, and ref that should be allowed to exchange a token, and issue short-lived sessions instead of long-lived static credentials.
+7. **Restrict your repository's Actions settings.** Allow-list the specific actions and reusable workflows your pipeline needs, require approval for fork-PR runs, and set the default workflow token to read-only.
+8. **Protect artifact integrity in the registry.** Use immutable tags and registry RBAC so a published artifact cannot be replaced, and deploy by the recorded digest where your deploy target supports it.
+9. **Review the generated YAML before adopting it**, especially anything copied from example repositories, and replace example defaults (a `tag` pin mode, inherited secrets) with the hardened settings above.
+
+## Wayfinding
+
+**Prerequisite:** [Generated workflows](/cascade/reference/generated-workflows/) for the anatomy of what these controls apply to.
+
+**Next:** [Architecture](/cascade/internals/architecture/) for how these boundaries fit the rest of the system design.
diff --git a/docs/src/content/docs/security/hardening.md b/docs/src/content/docs/security/hardening.md
deleted file mode 100644
index 551d42b4..00000000
--- a/docs/src/content/docs/security/hardening.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-title: Security and hardening
-description: Cascade's security model and a shared-responsibility guide to deploying it safely. It covers what cascade provides secure by construction versus what your organization configures in GitHub and your cloud, plus a concrete hardening checklist.
----
-
-Cascade generates GitHub Actions workflow definitions and coordinates promotion across environments. Those workflows are committed to, and run inside, your own repositories, under your own runners, branch protection, and environment gates. Security is shared: cascade emits sound, reviewable workflow definitions, and your organization configures the GitHub and cloud controls that decide what those workflows are allowed to do.
-
-This page describes the security model, then lays out both halves of the shared-responsibility split and gives you a checklist to harden a pipeline.
-
-## Security model
-
-Cascade is a build-time tool. It reads your manifest and writes workflow YAML that you commit and review in your own repository. There is no cascade-operated service in the request path at run time, and no cascade-held credential: every workflow runs under your repository's runners and your organization's policies.
-
-Cross-repo coordination uses a same-organization, shared-token model. When one repository hands off to another (for example, a satellite repository signaling its primary), the handoff is driven by a dispatch token that you provision and hold. That token is the trust boundary for cross-repo coordination: any party that holds it can trigger the coordinated workflow. Securing the token, scoping it tightly, and pairing it with the GitHub controls below is your responsibility. Treat the token as a production credential.
-
-Because the generated workflows live in your repository, they are deterministic and reviewable: you can read every job before you adopt it, pin it to a commit, and gate it with your own branch protection and environment rules.
-
-## The shared-responsibility model
-
-### What cascade provides (secure by construction)
-
-These properties hold for generated output today:
-
-- **Local reusable workflows are commit-pinned.** Workflows referenced as `./.github/...` are pinned to the calling commit, so your own callbacks resolve from a fixed commit rather than a moving branch.
-- **Every callback is a reusable workflow.** Validate, build, and deploy callbacks run as reusable workflows referenced by `workflow:`. cascade does not emit inline scripts on your behalf, so the script your pipeline runs is code you author and review in a workflow file rather than text generated from the manifest.
-- **The reusable-deploy gate boundary is surfaced at generate time.** GitHub does not allow a job-level `environment:` on a job that calls a reusable workflow. When you wire a reusable deploy, cascade warns you at generation time that the environment gate must live in the called workflow, so the requirement is explicit rather than silent.
-- **Generated workflows and commits are deterministic and reviewable.** Output is plain YAML committed to your repository. You can diff it, review it, and pin it before it runs.
-- **The artifact identifier is tracked end to end.** Cascade records an artifact digest in pipeline state alongside the human-readable version tag and can resolve it back later.
-
-### What is planned (roadmap, not available today)
-
-Treat these as future work rather than current guarantees:
-
-- Built-in authenticated cross-repo coordination (identity or signature verification on the receiver) beyond the shared dispatch token. Until then, the token plus your GitHub controls are the boundary.
-- Scoped, non-blanket secret passing by default for generated callers.
-- Deploying by immutable digest by default, rather than by version tag.
-- SHA-pinned third-party actions by default and provenance attestation.
-
-### What your organization must configure
-
-GitHub and your cloud own these controls; cascade cannot set them for you:
-
-- **Branch protection** on your trunk: require reviews and status checks, restrict who can push, and require signed commits where appropriate. The [`branch-protection`](/cli-reference/#branch-protection) command emits the matching settings, or applies them for you with `--apply` given a repo-admin token (the workflow `GITHUB_TOKEN` cannot).
-- **Tag protection or rulesets** so release and version tags cannot be moved or forged.
-- **Environment protection rules** with required reviewers, wait timers, and deployment branch or tag policies on production environments. For reusable deploys, place this gate in the called workflow.
-- **CODEOWNERS on workflow files** (`.github/workflows/**`) so changes to the pipeline itself require owner review.
-- **Restricted Actions settings:** an allow-list of permitted actions and reusable workflows, fork-PR run approval, and a default `GITHUB_TOKEN` that is read-only.
-- **An OIDC trust policy** in your cloud scoped to specific repository, environment, and ref, issuing short-lived role sessions instead of long-lived static credentials.
-- **Environment-scoped secrets** so production credentials are available only to the gated production job, not to every job in the repository.
-- **Scoped, short-lived tokens** for cross-repo coordination: prefer a GitHub App over a broad personal access token, and never replicate one long-lived token across many repositories.
-- **Artifact integrity controls** in your registry: immutable tags and registry RBAC so a published artifact cannot be swapped after it is produced.
-
-## Hardening checklist
-
-Work through this when standing up or reviewing a cascade pipeline. The order moves from the cross-repo trust boundary outward to the surrounding controls.
-
-1. **Secure the cross-repo dispatch token.** Store it as an environment-scoped secret, scope it to only the permissions the coordination needs, prefer a GitHub App or a short-lived token over a broad personal access token, and rotate it. Do not replicate one long-lived token across many repositories.
-2. **Protect trunk and tags, and add CODEOWNERS.** Require review on trunk and on `.github/workflows/**`, and protect release and version tags from being moved or deleted.
-3. **Gate every production deploy with a GitHub Environment.** Deploys run as reusable workflows, so add the `environment:` gate, required reviewers, and deployment branch or tag policy inside the called workflow, since the calling job cannot carry the gate.
-4. **Scope secrets to the job that needs them.** Replace blanket secret inheritance with an explicit list, and place production secrets behind an environment so only the gated production job can read them.
-5. **Restrict Actions settings.** Allow-list the specific actions and reusable workflows your pipeline needs, require approval for fork-PR runs, and set the default workflow token to read-only.
-6. **Use OIDC with a tightly scoped trust policy.** Scope cloud trust to the specific repository, environment, and ref, and issue short-lived sessions instead of long-lived static credentials.
-7. **Pin actions and reusable workflows.** Pin third-party actions and the cascade action reference by commit SHA rather than tracking a moving tag.
-8. **Protect artifact integrity in the registry.** Use immutable tags and registry RBAC so a published artifact cannot be replaced, and deploy by the recorded digest where your deploy supports it.
-9. **Review the generated YAML before adopting it,** especially anything copied from example repositories, and replace example defaults (mutable pins, inherited secrets) with the hardened settings above.
diff --git a/docs/src/content/docs/simulate.md b/docs/src/content/docs/simulate.md
deleted file mode 100644
index 94818199..00000000
--- a/docs/src/content/docs/simulate.md
+++ /dev/null
@@ -1,256 +0,0 @@
----
-title: Local Simulation
-description: Preview a promotion, release, rollback, or hotfix locally with cascade simulate. What the what-if engine validates, the before/after state diff and effect sequence it prints, how deploy stubs and outcome injection work, and where its scope ends.
----
-
-`cascade simulate` answers one question before you trigger a real pipeline: if I take this action against the manifest as it stands today, what would cascade do? It replays the same orchestration logic the live workflows use, in record-only mode, and prints a before/after state diff plus the ordered sequence of steps the orchestration would take. Nothing runs. No GitHub call is made, no container starts, no git command executes, and your manifest is never modified.
-
-Use it to sanity-check a promotion target, to see which environment a rollback would land on, to confirm a hotfix allocates the version you expect, or to preview how a failed deploy would gate the rest of the run.
-
-## What it validates, and what it does not
-
-This is the most important thing to understand about the tool, so read it before you trust a result.
-
-`cascade simulate` validates cascade's **orchestration**: the state transitions, and the run, skip, and gate decisions that move an artifact through your environments and across the prerelease/release boundary. It does **not** run your build and deploy scripts. Those workflows never execute in a simulation.
-
-A green simulation therefore means the orchestration would sequence correctly given the inputs you supplied. It is not a test that your deploy actually works. Build and deploy callbacks are recorded as stubbed steps with a simulated outcome (success by default), so the orchestration sequences exactly as it would if those callbacks had returned that outcome. To exercise your real scripts you still need the live pipeline, or the integration simulator described at the end of this page.
-
-Every simulation prints this boundary as a closing note so it stays in view:
-
-```
-Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts.
-```
-
-## How it runs
-
-The engine reads your manifest, copies it to a temporary file, computes the hypothetical transition against that copy, and discards the copy. The original bytes are untouched. The computation is deterministic: environment keys are sorted and run-stamped timestamps are excluded, so the same inputs always produce the same report.
-
-By default the manifest is auto-detected at `.github/manifest.yaml`. Point at another file with `--config`.
-
-```bash
-cascade simulate [flags]
-```
-
-The four actions are `promote`, `release`, `rollback`, and `hotfix`.
-
-## Reading the output
-
-Every action prints two parts.
-
-**State diff** shows what would change in the manifest state, per environment. Each line names a field and its before and after values, for example `version: (none) -> v1.2.0-rc.1`. When nothing would change, the diff reads `(no state change)`.
-
-**Effects (in order)** is the ordered sequence of steps the orchestration would take. Each step carries a disposition in brackets:
-
-| Disposition | Meaning |
-|-------------|---------|
-| `run` | The orchestration would carry this step out. |
-| `skip` | The orchestration would skip this step as a no-op. |
-| `gate` | The step is held back behind a gate or guard, for example a finalize blocked by a failed deploy. |
-
-## Promote
-
-Preview moving the current artifact one environment forward, or, in cascade mode, through every intermediate hop to a target.
-
-```bash
-cascade simulate promote
-cascade simulate promote --mode cascade --target dev-to-prod
-```
-
-Against a chain of `dev -> uat -> prod` where `dev` holds `v1.2.0-rc.1` and `uat` is empty:
-
-```
-Simulating: promote (mode=default)
-State diff:
- uat:
- version: (none) -> v1.2.0-rc.1
- sha: (none) -> a1b2c3d4e5f6
-Effects (in order):
- 1. [run] deploy uat (from dev (sha a1b2c3d, version v1.2.0-rc.1))
- 2. [run] write state uat (sha a1b2c3d, version v1.2.0-rc.1)
- 3. [run] release prerelease v1.2.0 (rc v1.2.0-rc.1, sha a1b2c3d)
- 4. [skip] promote prod (no change required)
-
-Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts.
-```
-
-### Flags
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--mode` | `default` | Promotion mode: `default` advances one environment, `cascade` carries state through every hop to the target. |
-| `--target` | (none) | Cascade target, for example `dev-to-prod`. |
-
-## Release
-
-Preview crossing the prerelease/release boundary. The report includes the prerelease or publish marker the orchestration would emit, which is the headline decision at this stage.
-
-```bash
-cascade simulate release
-```
-
-For a library or CLI project with no environments, where `prerelease` holds `v1.0.0-rc.0`, the crossing publishes:
-
-```
-Simulating: release (prerelease/publish crossing)
-State diff:
- prerelease:
- version: v1.0.0-rc.0 -> (none)
- sha: a1b2c3d4e5f6 -> (none)
- release:
- version: (none) -> v1.0.0
- sha: (none) -> a1b2c3d4e5f6
-Effects (in order):
- 1. [run] write state release (sha a1b2c3d, version v1.0.0)
- 2. [run] release publish v1.0.0 (rc v1.0.0-rc.0, sha a1b2c3d)
-
-Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts.
-```
-
-`release` takes no action-specific flags beyond the shared ones below.
-
-## Rollback
-
-Preview reverting an environment to a prior state. Target resolution is pinned to the in-state deploy-history ring, so the simulation never reads a git repository. With no `--to`, the engine resolves the previous distinct state from that ring.
-
-```bash
-cascade simulate rollback --env prod
-cascade simulate rollback --env prod --to v1.0.0
-```
-
-For a `prod` env currently on `v2.0.0` with one prior ring snapshot at `v1.0.0`:
-
-```
-Simulating: rollback (env=prod, to=previous)
-State diff:
- prod:
- version: v2.0.0 -> v1.0.0
- sha: newsha0000000 -> oldsha0000000
- divergence: no -> yes
- previous ring: 1 -> 2
-Effects (in order):
- 1. [run] revert prod (to sha oldsha0, version v1.0.0 (from previous-ring))
- 2. [run] write state prod (sha oldsha0, version v1.0.0)
-
-Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts.
-```
-
-### Flags
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--env` | (required) | Environment to roll back. |
-| `--to` | previous distinct state | Target SHA or version. |
-| `--deployable` | (none) | Scope the rollback to a single deployable. |
-
-## Hotfix
-
-Preview applying one or more trunk commits as a hotfix to an environment. The engine allocates the next hotfix version, snapshots the prior state into the ring, and writes the divergence fields.
-
-```bash
-cascade simulate hotfix --env uat --fix fixaaa1110000
-cascade simulate hotfix --env uat --fix fixaaa1110000,fixbbb2220000 --merge-sha mergesha00000
-```
-
-For a `uat` env on `v1.0.0-rc.1` carrying a single fix commit:
-
-```
-Simulating: hotfix (env=uat, commits=1)
-State diff:
- uat:
- version: v1.0.0-rc.1 -> v1.0.0-rc.1.hotfix.1
- sha: basesha000000 -> fixaaa1110000
- divergence: no -> yes
- previous ring: 0 -> 1
-Effects (in order):
- 1. [run] apply patch uat (commit fixaaa1)
- 2. [run] write state uat (diverge env onto integration branch)
- 3. [run] release create uat (tag v1.0.0-rc.1.hotfix.1)
- 4. [run] release prerelease uat (tag v1.0.0-rc.1.hotfix.1)
-
-Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts.
-```
-
-Each carried commit yields its own `apply patch` step. With multiple commits the environment advances to `--merge-sha`, the resolution-branch tip, defaulting to the first fix commit when omitted.
-
-### Flags
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--env` | (required) | Environment to hotfix. |
-| `--fix` | (required) | Comma-separated trunk commit SHAs the hotfix carries. |
-| `--merge-sha` | first fix commit | Resolution-branch tip the environment advances to. |
-
-## Deploy stubs and outcome injection
-
-Because real callbacks never run, each build and deploy a manifest declares is recorded as a stubbed step instead of an execution. By default every stub resolves to success, so the orchestration sequences as if all callbacks had passed.
-
-To preview gating, inject a per-callback outcome with `--deploy-result name=outcome`, where outcome is `success`, `failure`, or `skipped`. The flag is repeatable, so you can set an outcome for each callback by name. A deploy that did not succeed holds back the simulated finalize, mirroring how the live finalizers refuse to record trunk state when a deploy fails.
-
-With a manifest that declares a `services` deploy, a successful run records the stub and finalizes normally:
-
-```
-Effects (in order):
- 1. [run] deploy uat (from dev (sha a1b2c3d, version v1.2.0-rc.1))
- 2. [run] deploy services (simulated success (not executed))
- 3. [run] write state uat (sha a1b2c3d, version v1.2.0-rc.1)
- 4. [run] release prerelease v1.2.0 (rc v1.2.0-rc.1, sha a1b2c3d)
- 5. [skip] promote prod (no change required)
-```
-
-Inject a failure and the finalize is gated:
-
-```bash
-cascade simulate promote --deploy-result services=failure
-```
-
-```
-Effects (in order):
- 1. [run] deploy uat (from dev (sha a1b2c3d, version v1.2.0-rc.1))
- 2. [run] deploy services (simulated failure (not executed))
- 3. [gate] write state uat (deploy "services" simulated failure; trunk state left unchanged)
- 4. [run] release prerelease v1.2.0 (rc v1.2.0-rc.1, sha a1b2c3d)
- 5. [skip] promote prod (no change required)
-```
-
-A `skipped` outcome is never a failure, but it does not count as a success either. When every configured deploy is skipped, nothing was deployed, so the finalize still gates.
-
-## Shared flags
-
-These flags apply to every action.
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--config` | `.github/manifest.yaml` | Path to the manifest file. |
-| `--actor` | (none) | Actor performing the hypothetical action. |
-| `--deploy-result` | (none) | Simulated outcome for a build or deploy callback, `name=success\|failure\|skipped`. Repeatable. |
-| `--json` | `false` | Emit the result as deterministic JSON instead of the human report. |
-
-## JSON output
-
-Pass `--json` for a machine-readable result. The structure carries the action, its description, the full diff, and the effect list, suitable for piping into `jq` or asserting on in a script.
-
-```bash
-cascade simulate promote --json
-```
-
-```json
-{
- "action": "promote",
- "describe": "promote (mode=default)",
- "diff": {
- "envs": [
- {
- "environment": "uat",
- "version": { "field": "version", "from": "(none)", "to": "v1.2.0-rc.1", "changed": true },
- "sha": { "field": "sha", "from": "(none)", "to": "a1b2c3d4e5f6", "changed": true }
- }
- ]
- }
-}
-```
-
-## A higher-fidelity simulator is planned
-
-`cascade simulate` is the fast, dependency-free way to check orchestration decisions, and it covers most day-to-day what-if questions. It deliberately stops at the orchestration boundary.
-
-A second, higher-fidelity simulator is planned. It will run the generated workflows locally with [act](https://github.com/nektos/act) and a local [Gitea](https://about.gitea.com/) server, exercising the real workflow YAML end to end with stubbed deploys, at the cost of requiring Docker. That work is tracked in the [issue tracker](https://github.com/stablekernel/cascade/issues). Until it lands, reach for the live pipeline when you need to validate the workflows or your deploy scripts themselves.
diff --git a/docs/src/content/docs/stage-graph.md b/docs/src/content/docs/stage-graph.md
deleted file mode 100644
index be02a987..00000000
--- a/docs/src/content/docs/stage-graph.md
+++ /dev/null
@@ -1,128 +0,0 @@
----
-title: Stage Graph
-description: The mental model for cascade. How trunk, the environment chain, and the prerelease/release boundary fit together, with rollback and hotfix off-ramps, and the manifest field and generated workflow behind each stage.
----
-
-Cascade turns one manifest into a chain of stages. A change starts on trunk, moves through your environments in order, and crosses a prerelease/release boundary near the top of the chain. Two off-ramps, rollback and hotfix, branch off that line when something needs to move backward or sideways. This page is the map. It names every stage, ties each to the manifest field that turns it on and the generated workflow file that runs it, and says when it fires. Once the shape is clear, the deeper pages fill in the detail.
-
-## The graph
-
-```mermaid
-flowchart TD
- trunk["Trunk push (orchestrate.yaml)"]
- subgraph chain["Environment chain (environments order)"]
- direction TB
- e1["environments[0] build target"]
- e2["...middle environments..."]
- pre["environments[N-1] prerelease marker"]
- rel["environments[N] release marker"]
- e1 --> e2 --> pre --> rel
- end
- trunk --> e1
- promote["Promote (promote.yaml)"]
- promote -. drives .-> chain
- rollback["Rollback off-ramp (cascade-rollback.yaml)"]
- hotfix["Hotfix off-ramp (cascade-hotfix.yaml)"]
- chain --> rollback
- chain --> hotfix
- hotfix -. patched ref .-> chain
-```
-
-The single-environment case collapses this: one environment gets a release workflow (`release.yaml`) instead of the promote chain.
-
-## Stages
-
-Each line below is one stage: the manifest field that enables it, the generated file that realizes it, and when it fires. The files are exactly what `cascade generate-workflow` writes, and `cascade verify` keeps them in sync with the manifest.
-
-### Trunk -> orchestrate
-
-Every push to your trunk branch runs the orchestrate workflow, which builds and deploys the first environment.
-
-- **Manifest field:** `trunk_branch`, with the paths filter from `triggers` (or the per-callback triggers).
-- **Generated file:** `orchestrate.yaml`.
-- **Fires on:** `push` to `trunk_branch` (filtered by `triggers`) and `workflow_dispatch`. Optional `repository_dispatch`, `workflow_run`, and `merge_group` triggers come from `extra_triggers`.
-
-### Environment chain -> promote
-
-The `environments` list is the spine of the graph. Its order is the promotion order: a change moves from each environment to the next, one step at a time, carrying the same built artifact forward.
-
-- **Manifest field:** `environments` (an ordered list).
-- **Generated file:** `promote.yaml` for two or more environments; `release.yaml` for a single environment.
-- **Fires on:** `workflow_dispatch`. You pick a promotion target (for example `dev-to-uat`) and the run advances every environment from the source up to that target.
-
-### The prerelease/release boundary
-
-The boundary is positional, not a named environment. The second-from-top environment is the prerelease marker: promoting into it cuts a prerelease. The top environment is the release marker: promoting into it publishes the final release. For `environments: [dev, test, uat, prod]`, promoting to `uat` marks a prerelease and promoting to `prod` publishes the release.
-
-- **Manifest field:** position within `environments` (last = release, second-from-last = prerelease).
-- **Generated file:** `promote.yaml` (the same workflow; the boundary is a property of which target you promote to).
-- **Fires on:** the promote run whose target is the prerelease or release environment.
-
-### Rollback off-ramp
-
-Rollback moves an environment backward to a previously recorded state. It reads the deploy history cascade keeps in manifest state.
-
-- **Manifest field:** `rollback` (and at least one environment).
-- **Generated file:** `cascade-rollback.yaml`.
-- **Fires on:** `workflow_dispatch`, plus `repository_dispatch` when `rollback.repository_dispatch` is set so an external signal can trigger it.
-
-### Hotfix off-ramp
-
-Hotfix patches an environment off a divergent branch instead of trunk, so an urgent fix can ship to one environment without waiting for the full chain. The patched ref rejoins the chain through manifest state.
-
-- **Manifest field:** two or more `environments` (the hotfix workflow is emitted whenever the environment chain can diverge).
-- **Generated file:** `cascade-hotfix.yaml`.
-- **Fires on:** `workflow_dispatch`, and `pull_request` (closed) on hotfix branches to finalize the patch.
-
-## Supporting stages
-
-These stages are opt-in. They guard the graph rather than move artifacts through it, so they sit alongside the chain rather than on it.
-
-### External coordination
-
-A primary repo coordinates deploys in other repos. When the manifest lists `external` repos, cascade emits a receiver workflow that records or runs external updates.
-
-- **Manifest field:** `external`.
-- **Generated file:** `external-update.yaml`.
-- **Fires on:** `workflow_dispatch` (dispatched by the satellite repo after its own deploy).
-
-### Validate check
-
-A pull-request gate that runs your validation callback before merge.
-
-- **Manifest field:** `validate_check.enabled`.
-- **Generated file:** `cascade-validate.yaml`.
-- **Fires on:** `pull_request`.
-
-### Merge queue
-
-Runs orchestration for GitHub's merge queue so queued changes are checked together.
-
-- **Manifest field:** `merge_queue.enabled`.
-- **Generated file:** `cascade-merge-queue.yaml`.
-- **Fires on:** `merge_group`.
-
-### PR preview
-
-Spins up a preview for a pull request.
-
-- **Manifest field:** `pr_preview.enabled`.
-- **Generated file:** `cascade-pr-preview.yaml`.
-- **Fires on:** `pull_request`.
-
-### Drift check
-
-Fails a pull request when the committed workflows fall out of sync with the manifest. An optional fork-safe companion posts the result as a comment.
-
-- **Manifest field:** `drift_check.enabled` (and `drift_check.comment` for the companion).
-- **Generated file:** `cascade-drift-check.yaml`, plus `cascade-drift-comment.yaml` when `comment` is set.
-- **Fires on:** `pull_request` for the check; `workflow_run` (base-repo context) for the comment companion. See the [drift-check configuration](/cascade/configuration/#drift-check-workflow-opt-in) for the pin-mode recommendation when you enable the comment lane.
-
-## Where to go next
-
-This page is the entry point. Follow the threads from here:
-
-- [Manifest Reference](/cascade/configuration/): every field behind every stage above.
-- [Workflows](/cascade/workflows/): what orchestrate, promote, and release do step by step.
-- [Architecture](/cascade/architecture/): how the generator turns the manifest into these files.
-- [CLI Reference](/cascade/cli-reference/): `generate-workflow`, `verify`, and the rest of the commands.
diff --git a/docs/src/content/docs/start/getting-started.md b/docs/src/content/docs/start/getting-started.md
new file mode 100644
index 00000000..5d19136a
--- /dev/null
+++ b/docs/src/content/docs/start/getting-started.md
@@ -0,0 +1,238 @@
+---
+title: Getting started
+description: Install the Cascade CLI, write a manifest and callbacks, generate your workflows, and run your first pipeline.
+---
+
+This tutorial takes a fresh repository to a running Cascade pipeline. It is the canonical install page: every other page links here instead of repeating install steps.
+
+## Prerequisites
+
+- Go 1.25 or newer, to install the CLI.
+- A GitHub repository with Actions enabled.
+- Trunk-based development: one primary branch (this guide assumes `main`).
+
+## Install
+
+```bash
+go install github.com/stablekernel/cascade/cmd/cascade@latest
+```
+
+`@latest` tracks the newest tagged release. To pin an exact version instead:
+
+```bash
+go install github.com/stablekernel/cascade/cmd/cascade@v0.9.1
+```
+
+Verify the install:
+
+```bash
+cascade version
+```
+
+You rarely need to invoke Cascade manually in CI: generated workflows install it for you via the `setup-cli` composite action, pinned to whatever `cli_version` you set in your manifest. If you need to call it directly in a workflow of your own:
+
+```yaml
+- uses: stablekernel/cascade/.github/actions/setup-cli@v0.9.1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+```
+
+The action downloads the release archive, extracts it, and puts `cascade` on `PATH`.
+
+## Scaffold with `cascade init`
+
+The fastest path to a working manifest is `cascade init`. It renders the manifest and callback stubs, verifies them through the real generator, and writes them into your repository:
+
+```bash
+# Two-environment pipeline (the default topology)
+cascade init --topology two-env
+
+# Or choose your own ordered environments; the last is the release stage
+cascade init --envs staging,production --name my-service
+
+# Preview without writing anything
+cascade init --topology two-env --dry-run
+```
+
+Topologies: `no-env` (library or CLI project, no deployments), `two-env` (the default), `three-env`, and `four-env`. Each produces `.github/manifest.yaml` plus build and deploy stubs under `.github/workflows/`. If a target file already exists, `init` aborts and lists the conflicts unless you pass `--force`.
+
+Once scaffolded, skip to [write your callbacks](#write-your-callbacks) to fill in the stubs. The manual walkthrough below builds the same files by hand if you would rather start from scratch.
+
+## Or write the manifest by hand
+
+Create `.github/manifest.yaml`:
+
+```yaml
+ci:
+ config:
+ schema_version: 1
+ trunk_branch: main
+ environments: [dev, test, prod]
+ cli_version: v0.9.1
+
+ validate:
+ workflow: .github/workflows/validate.yaml
+
+ builds:
+ - name: app
+ workflow: .github/workflows/build-app.yaml
+ triggers:
+ - "src/**"
+ - "Dockerfile"
+ - "go.mod"
+
+ deploys:
+ - name: infra
+ workflow: .github/workflows/deploy-infra.yaml
+ triggers:
+ - "infra/**"
+
+ - name: services
+ workflow: .github/workflows/deploy-services.yaml
+ depends_on: [app]
+
+ changelog:
+ contributors: true
+
+ state:
+ dev: {}
+ test: {}
+ prod: {}
+```
+
+Cascade owns `state:` and `latest_release:`. The empty `state: { dev: {}, ... }` skeleton is enough to start; every run fills in the details. See the [manifest reference](/cascade/reference/manifest/) for every field.
+
+### No-environment mode
+
+Library and CLI projects that publish releases without deployments can omit `environments` entirely:
+
+```yaml
+ci:
+ config:
+ schema_version: 1
+ trunk_branch: main
+ cli_version: v0.9.1
+ builds:
+ - name: cli
+ workflow: .github/workflows/build-cli.yaml
+ triggers: ["cmd/**", "internal/**", "go.mod"]
+ changelog:
+ contributors: true
+```
+
+Commits create RC pre-releases automatically; a `promote` dispatch (default mode) publishes the final release.
+
+## Write your callbacks
+
+Write your callback workflows **before** you run `generate-workflow`: the generator reads each callback's `on: workflow_call` block to discover its declared outputs, so a callback that does not exist yet cannot be wired in. Follow the [callback contract](/cascade/reference/callbacks/) for the exact input and output shape.
+
+`.github/workflows/build-app.yaml`:
+
+```yaml
+name: Build App
+
+on:
+ workflow_call:
+ inputs:
+ environment:
+ type: string
+ required: true
+ sha:
+ type: string
+ required: true
+ dry_run:
+ type: boolean
+ required: false
+ default: false
+ outputs:
+ artifact_id:
+ description: Immutable artifact identifier (e.g., image digest)
+ value: ${{ jobs.build.outputs.artifact_id }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ outputs:
+ artifact_id: ${{ steps.push.outputs.digest }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.sha }}
+ - name: Build and push
+ id: push
+ if: ${{ !inputs.dry_run }}
+ run: |
+ docker build -t myrepo/app:${{ inputs.sha }} .
+ docker push myrepo/app:${{ inputs.sha }}
+ DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' myrepo/app:${{ inputs.sha }} | cut -d@ -f2)
+ echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
+```
+
+`.github/workflows/deploy-services.yaml`:
+
+```yaml
+name: Deploy Services
+
+on:
+ workflow_call:
+ inputs:
+ environment:
+ type: string
+ required: true
+ sha:
+ type: string
+ required: true
+ dry_run:
+ type: boolean
+ required: false
+ default: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment: ${{ inputs.environment }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.sha }}
+ - name: Deploy
+ if: ${{ !inputs.dry_run }}
+ run: echo "Deploying ${{ inputs.sha }} to ${{ inputs.environment }}"
+```
+
+If you configured `publish:` in the manifest, add a matching callback that retags an RC's artifact once it is published as a final release; see the [callback contract](/cascade/reference/callbacks/) for its inputs.
+
+## Generate
+
+```bash
+# Preview
+cascade generate-workflow --dry-run
+
+# Write the files
+cascade generate-workflow --force
+```
+
+For the manifest above (two or more environments, no opt-in lanes configured), this writes:
+
+- `.github/workflows/orchestrate.yaml`, runs on merge to trunk.
+- `.github/workflows/promote.yaml`, handles manual promotion between environments.
+- `.github/workflows/cascade-hotfix.yaml`, the hotfix off-ramp (emitted whenever you have two or more environments).
+- `.github/workflows/cascade-rollback.yaml`, the rollback off-ramp (same condition).
+- `.github/actions/manage-release/action.yaml`, the composite action that creates and updates GitHub Releases.
+
+Opt-in companions such as `validate_check`, `merge_queue`, `pr_preview`, `drift_check`, and `external` add their own files only when you configure them; see [generated workflows reference](/cascade/reference/generated-workflows/) for the full set and each file's anatomy.
+
+## Commit and run
+
+```bash
+git add .github/manifest.yaml .github/workflows/
+git commit -m "feat: add trunk-based CI/CD"
+git push origin main
+```
+
+The orchestrate workflow runs automatically on the next merge to `main`: it detects which builds and deploys your changed files trigger, runs them for the first environment, and opens or updates a draft pre-release. From there, dispatch the promote workflow from the Actions tab to advance a change through the rest of your environments.
+
+---
+
+**Prerequisite:** [How Cascade works](/cascade/start/how-it-works/).
+**Next:** the [callback contract](/cascade/reference/callbacks/) for the full input/output shape, then [promote a release](/cascade/guides/promote/) to run your first promotion.
diff --git a/docs/src/content/docs/start/how-it-works.md b/docs/src/content/docs/start/how-it-works.md
new file mode 100644
index 00000000..c93c5793
--- /dev/null
+++ b/docs/src/content/docs/start/how-it-works.md
@@ -0,0 +1,90 @@
+---
+title: How Cascade works
+description: The mental model for Cascade. How trunk, the environment chain, and the prerelease/release boundary fit together, with rollback and hotfix off-ramps and the supporting lanes around them.
+---
+
+Cascade turns one manifest into a chain of stages. A change starts on trunk, moves through your environments in order, and crosses a prerelease/release boundary near the top of the chain. Two off-ramps, rollback and hotfix, branch off that line when something needs to move backward or sideways. This page is the map: it names every stage and says when it fires. The [getting started](/cascade/start/getting-started/) tutorial and the task guides fill in the how.
+
+## The model in one diagram
+
+```mermaid
+flowchart TD
+ trunk["Trunk push (orchestrate.yaml)"]
+ subgraph chain["Environment chain (environments order)"]
+ direction TB
+ e1["environments[0] build target"]
+ e2["...middle environments..."]
+ pre["environments[N-1] prerelease marker"]
+ rel["environments[N] release marker"]
+ e1 --> e2 --> pre --> rel
+ end
+ trunk --> e1
+ promote["Promote (promote.yaml)"]
+ promote -. drives .-> chain
+ rollback["Rollback off-ramp (cascade-rollback.yaml)"]
+ hotfix["Hotfix off-ramp (cascade-hotfix.yaml)"]
+ chain --> rollback
+ chain --> hotfix
+ hotfix -. patched ref .-> chain
+```
+
+The single-environment case collapses this: `promote.yaml` holds release-workflow content instead of the promote chain, so the one environment publishes directly.
+
+## Trunk and the environment chain
+
+Every push to your trunk branch runs the orchestrate workflow, which builds and deploys the first environment.
+
+- **Manifest field:** `trunk_branch`, with the paths filter from `triggers` (or the per-callback triggers).
+- **Generated file:** `orchestrate.yaml`.
+- **Fires on:** `push` to `trunk_branch` (filtered by `triggers`) and `workflow_dispatch`. Optional `repository_dispatch`, `workflow_run`, and `merge_group` triggers come from `extra_triggers`.
+
+The `environments` list is the spine of the graph. Its order is the promotion order: a change moves from each environment to the next, one step at a time, carrying the same built artifact forward.
+
+- **Manifest field:** `environments` (an ordered list).
+- **Generated file:** `promote.yaml`.
+- **Fires on:** `workflow_dispatch`. You pick a promotion target (for example `dev-to-uat`) and the run advances every environment from the source up to that target.
+
+## The release boundary
+
+The boundary is positional, not a named environment. The second-from-top environment is the prerelease marker: promoting into it cuts a prerelease. The top environment is the release marker: promoting into it publishes the final release. For `environments: [dev, test, uat, prod]`, promoting to `uat` marks a prerelease and promoting to `prod` publishes the release.
+
+- **Manifest field:** position within `environments` (last = release, second-from-last = prerelease).
+- **Generated file:** `promote.yaml` (the same workflow; the boundary is a property of which target you promote to).
+- **Fires on:** the promote run whose target is the prerelease or release environment.
+
+## Off-ramps: hotfix and rollback
+
+**Rollback** moves an environment backward to a previously recorded state. It reads the deploy history Cascade keeps in manifest state.
+
+- **Manifest field:** at least two `environments`; `rollback` is optional and adds an external trigger.
+- **Generated file:** `cascade-rollback.yaml`.
+- **Fires on:** `workflow_dispatch`, plus `repository_dispatch` when `rollback.repository_dispatch` is set so an external signal can trigger it.
+
+**Hotfix** patches an environment off a divergent branch instead of trunk, so an urgent fix can ship to one environment without waiting for the full chain. The patched ref rejoins the chain through manifest state.
+
+- **Manifest field:** two or more `environments` (the hotfix workflow is emitted whenever the environment chain can diverge).
+- **Generated file:** `cascade-hotfix.yaml`.
+- **Fires on:** `workflow_dispatch`, and `pull_request` (closed) on hotfix branches to finalize the patch.
+
+For the operator-level walkthrough of each, see [Run a hotfix](/cascade/guides/hotfix/) and [Roll back an environment](/cascade/guides/rollback/).
+
+## Supporting lanes
+
+These lanes are opt-in. They guard the graph rather than move artifacts through it, so they sit alongside the chain rather than on it.
+
+| Lane | Manifest field | Generated file | Fires on |
+|---|---|---|---|
+| External coordination | `external` | `external-update.yaml` | `workflow_dispatch`, dispatched by a satellite repo after its own deploy |
+| Validate check | `validate_check.enabled` | `cascade-validate.yaml` | `pull_request` |
+| Merge queue | `merge_queue.enabled` | `cascade-merge-queue.yaml` | `merge_group` |
+| PR preview | `pr_preview.enabled` | `cascade-pr-preview.yaml` | `pull_request` |
+| Drift check | `drift_check.enabled` (`drift_check.comment` for the companion) | `cascade-drift-check.yaml`, plus `cascade-drift-comment.yaml` when `comment` is set | `pull_request` for the check; `workflow_run` for the comment companion |
+
+A primary repo coordinates deploys in other repos through the external lane: when the manifest lists `external` repos, Cascade emits a receiver workflow that records or runs external updates. PR preview spins up a preview environment for a pull request; see its field in the [manifest reference](/cascade/reference/manifest/). Drift check fails a pull request when the committed workflows fall out of sync with the manifest.
+
+Every field above is documented in full in the [manifest reference](/cascade/reference/manifest/), and every generated file's internal anatomy is in [generated workflows reference](/cascade/reference/generated-workflows/).
+
+---
+
+**Prerequisite:** [Why Cascade](/cascade/start/why-cascade/).
+**Next:** [Getting started](/cascade/start/getting-started/) to build your first pipeline.
diff --git a/docs/src/content/docs/comparison.md b/docs/src/content/docs/start/why-cascade.md
similarity index 59%
rename from docs/src/content/docs/comparison.md
rename to docs/src/content/docs/start/why-cascade.md
index 922a6979..d5eabe98 100644
--- a/docs/src/content/docs/comparison.md
+++ b/docs/src/content/docs/start/why-cascade.md
@@ -1,93 +1,98 @@
---
title: Why Cascade
-description: What cascade is, who it is for, and how it relates to release tooling, promotion control planes, and CI-as-code generators.
+description: What Cascade is, who it is for, and how it compares to release tooling, promotion control planes, and CI-as-code generators.
---
-This page helps you decide whether cascade fits your repository, and explains how it relates to the adjacent tools you may already use. The short version: cascade is a compiler, not a control plane. It reads a manifest and writes plain GitHub Actions workflows that you own. There is no platform to run, no cluster, and no agent.
+This page helps you decide whether Cascade fits your repository, and explains how it relates to the adjacent tools you may already use. The short version: Cascade is a compiler, not a control plane. It reads a manifest and writes plain GitHub Actions workflows that you own. There is no platform to run, no cluster, and no agent.
-## What cascade is
+## What Cascade is
-cascade is a Go CLI that compiles a single declarative manifest into native GitHub Actions workflows for multi-environment release and promotion. It also derives versions and changelogs from your Conventional Commits.
+Cascade is a Go CLI that compiles a single declarative manifest into native GitHub Actions workflows for multi-environment release and promotion. It also derives versions and changelogs from your Conventional Commits.
-You run `cascade generate-workflow` once. From then on the generated workflows own their own execution. The output is ordinary YAML that lives in your repository under `.github/workflows/`. If you stop using cascade tomorrow, the workflows it wrote keep running exactly as they did, because they are yours. Nothing about them depends on a hosted runtime.
+You run `cascade generate-workflow` once. From then on the generated workflows own their own execution. The output is ordinary YAML that lives in your repository under `.github/workflows/`. If you stop using Cascade tomorrow, the workflows it wrote keep running exactly as they did, because they are yours. Nothing about them depends on a hosted runtime.
-That is the whole identity: cascade is build-time tooling that produces artifacts you keep, not a system you adopt and depend on at runtime.
+That is the whole identity: Cascade is build-time tooling that produces artifacts you keep, not a system you adopt and depend on at runtime.
## Who it is for, and when to use it
-cascade earns its keep when you promote a built artifact through a chain of environments. It is a strong fit when most of these hold:
+Cascade earns its keep when you promote a built artifact through a chain of environments. It is a strong fit when most of these hold:
- You deploy to **two or more environments** (say dev, test, prod) and want the *same* artifact promoted through them, never rebuilt per stage.
- You are on **GitHub Actions** and would rather own your deploy logic in reusable workflows than run a separate CD platform.
- You want **promotion gates, hotfix-to-any-environment, and rollback** without hand-wiring that state machine.
-- You can adopt **Conventional Commits**, from which cascade derives versions, changelogs, and the breaking-change gate.
+- You can adopt **Conventional Commits**, from which Cascade derives versions, changelogs, and the breaking-change gate.
## When not to use it
-cascade is likely overkill for a single environment with a plain build-and-release on push, or for a repository with no deployments at all. (The no-environment mode still gives you Conventional-Commit versioning and releases if you want just that.)
+Cascade is likely overkill for a single environment with a plain build-and-release on push, or for a repository with no deployments at all. (The no-environment mode still gives you Conventional-Commit versioning and releases if you want just that.)
-A few deliberate non-goals are worth stating plainly, because they shape what cascade will and will not do for you:
+A few deliberate non-goals are worth stating plainly, because they shape what Cascade will and will not do for you:
-- **Trunk-based only.** cascade promotes *from trunk*: you merge to one trunk branch and cascade promotes that line through your environments. If you run release branches or a GitFlow model today, adopting cascade means moving promotion onto a trunk-based flow. That is a deliberate shift. cascade is a practical vehicle for it, but it does not model long-lived release branches.
-- **You own the deploy logic.** Build, deploy, validate, and publish are *your* logic, supplied as reusable (`workflow_call`) workflows that cascade calls with a fixed input contract. cascade calls build and deploy as separate stages, so a pipeline that fuses them into one workflow today gets split into a build callback and a deploy callback on adoption. cascade never runs your scripts inline and never reaches into your callback logic.
-- **It never rebuilds artifacts per stage.** cascade promotes the artifact that was built once, pinning each promotion to a specific SHA. It does not rebuild between environments.
-- **It is a metadata courier.** cascade passes artifact identifiers and versions between stages. It never touches your container registry, package registry, or deployment target directly. You construct those operations yourself in your callbacks.
+- **Trunk-based only.** Cascade promotes *from trunk*: you merge to one trunk branch and Cascade promotes that line through your environments. If you run release branches or a GitFlow model today, adopting Cascade means moving promotion onto a trunk-based flow. That is a deliberate shift. Cascade is a practical vehicle for it, but it does not model long-lived release branches.
+- **You own the deploy logic.** Build, deploy, validate, and publish are *your* logic, supplied as reusable (`workflow_call`) workflows that Cascade calls with a fixed input contract. Cascade calls build and deploy as separate stages, so a pipeline that fuses them into one workflow today gets split into a build callback and a deploy callback on adoption. Cascade never runs your scripts inline and never reaches into your callback logic.
+- **It never rebuilds artifacts per stage.** Cascade promotes the artifact that was built once, pinning each promotion to a specific SHA. It does not rebuild between environments.
+- **It is a metadata courier.** Cascade passes artifact identifiers and versions between stages. It never touches your container registry, package registry, or deployment target directly. You construct those operations yourself in your callbacks.
-If you need a tool that runs your deployments for you, manages a cluster, or owns the runtime path to production, cascade is the wrong layer. See the next section for tools built for that job.
+If you need a tool that runs your deployments for you, manages a cluster, or owns the runtime path to production, Cascade is the wrong layer. See the next section for tools built for that job.
-## How cascade relates to adjacent tools
+## How Cascade relates to adjacent tools
-The space around cascade is crowded, but most tools sit on a single axis. cascade sits at the intersection of three, and on each axis it has a different goal from the specialists there. None of the comparisons below are about better or worse; they are about different jobs. In several cases the right answer is to use cascade *alongside* one of these tools.
+The space around Cascade is crowded, but most tools sit on a single axis. Cascade sits at the intersection of three, and on each axis it has a different goal from the specialists there. None of the comparisons below are about better or worse; they are about different jobs. In several cases the right answer is to use Cascade *alongside* one of these tools.
### Release, versioning, and changelogs from Conventional Commits
These tools turn your commit history into versions, changelogs, and releases.
-| Tool | What it does well | How cascade differs |
+| Tool | What it does well | How Cascade differs |
|---|---|---|
-| [release-please](https://github.com/googleapis/release-please) | Maintains a standing release pull request; merging it cuts the tag and release. | cascade derives versions and changelogs too, but its focus is promoting an artifact across environments rather than the standalone release-PR flow. The two pair well: you can let release-please run the release PR inside a callback while cascade owns promotion. |
-| [semantic-release](https://github.com/semantic-release/semantic-release) | Fully automated version, changelog, and publish on every qualifying commit. | cascade ties releasing to a promotion lifecycle (draft, prerelease, published) rather than a single publish step. |
-| [Changesets](https://github.com/changesets/changesets) | Author-written change files, strong for multi-package JS monorepos. | cascade reads Conventional Commits rather than change files, and centers environments rather than package graphs. |
-| [GoReleaser](https://goreleaser.com/) | Builds and publishes Go (and other) release artifacts and packages. | cascade does not build or publish artifacts itself; it can call GoReleaser as a build or publish callback. |
+| [release-please](https://github.com/googleapis/release-please) | Maintains a standing release pull request; merging it cuts the tag and release. | Cascade derives versions and changelogs too, but its focus is promoting an artifact across environments rather than the standalone release-PR flow. The two pair well: you can let release-please run the release PR inside a callback while Cascade owns promotion. |
+| [semantic-release](https://github.com/semantic-release/semantic-release) | Fully automated version, changelog, and publish on every qualifying commit. | Cascade ties releasing to a promotion lifecycle (draft, prerelease, published) rather than a single publish step. |
+| [Changesets](https://github.com/changesets/changesets) | Author-written change files, strong for multi-package JS monorepos. | Cascade reads Conventional Commits rather than change files, and centers environments rather than package graphs. |
+| [GoReleaser](https://goreleaser.com/) | Builds and publishes Go (and other) release artifacts and packages. | Cascade does not build or publish artifacts itself; it can call GoReleaser as a build or publish callback. |
-These tools and cascade are **complementary, not mutually exclusive.** You can point cascade's changelog or release step at your own workflow, or switch that step off, and let a tool like release-please or GoReleaser keep doing what it already does inside a reusable-workflow callback while cascade owns the promotion across environments. See the [Adoption Guide](/cascade/adoption/) for wiring this up.
+These tools and Cascade are **complementary, not mutually exclusive.** You can point Cascade's changelog or release step at your own workflow, or switch that step off, and let a tool like release-please or GoReleaser keep doing what it already does inside a reusable-workflow callback while Cascade owns the promotion across environments. See the [adoption guide](/cascade/guides/adopt/) for wiring this up.
### Multi-environment promotion and progressive rollout
These tools move releases through environments at runtime, and several add progressive rollout strategies.
-| Tool | What it does well | How cascade differs |
+| Tool | What it does well | How Cascade differs |
|---|---|---|
-| [Argo CD](https://argo-cd.readthedocs.io/) + [Argo Rollouts](https://argoproj.github.io/rollouts/) | GitOps continuous reconciliation for Kubernetes, with progressive rollout strategies. | cascade is a build-time generator, not a reconciling controller, and is not tied to Kubernetes. |
-| [Kargo](https://kargo.akuity.io/) | Stage-to-stage promotion of "Freight" through environments. | cascade shares this promotion mental model but is not a control plane you run; it emits Actions YAML instead. |
-| [Spinnaker](https://spinnaker.io/) | Mature multi-cloud deployment pipelines with rich stages. | cascade keeps the pipeline as GitHub Actions you own, rather than a separate pipeline platform. |
-| [Octopus Deploy](https://octopus.com/) | Release management and deployment automation across many targets. | cascade does not run deployments or hold a server-side release database; state lives in your manifest. |
-| [Harness](https://www.harness.io/) | A broad platform spanning CI, CD, and feature management. | cascade is a focused CLI, not a platform; it generates workflows and then steps out of the way. |
+| [Argo CD](https://argo-cd.readthedocs.io/) + [Argo Rollouts](https://argoproj.github.io/rollouts/) | GitOps continuous reconciliation for Kubernetes, with progressive rollout strategies. | Cascade is a build-time generator, not a reconciling controller, and is not tied to Kubernetes. |
+| [Kargo](https://kargo.akuity.io/) | Stage-to-stage promotion of "Freight" through environments. | Cascade shares this promotion mental model but is not a control plane you run; it emits Actions YAML instead. |
+| [Spinnaker](https://spinnaker.io/) | Mature multi-cloud deployment pipelines with rich stages. | Cascade keeps the pipeline as GitHub Actions you own, rather than a separate pipeline platform. |
+| [Octopus Deploy](https://octopus.com/) | Release management and deployment automation across many targets. | Cascade does not run deployments or hold a server-side release database; state lives in your manifest. |
+| [Harness](https://www.harness.io/) | A broad platform spanning CI, CD, and feature management. | Cascade is a focused CLI, not a platform; it generates workflows and then steps out of the way. |
-The important distinction across this whole row: these are **runtime control planes you adopt.** You run them (or pay for them), they hold pipeline state, and they often assume Kubernetes. cascade takes a different shape: it generates native GitHub Actions you keep, holds state in your manifest in your repository, and has nothing running between promotions. If you already operate one of these platforms and it serves you, cascade is not trying to replace it. cascade is for teams who would rather stay inside GitHub Actions than take on a separate runtime.
+The important distinction across this whole row: these are **runtime control planes you adopt.** You run them (or pay for them), they hold pipeline state, and they often assume Kubernetes. Cascade takes a different shape: it generates native GitHub Actions you keep, holds state in your manifest in your repository, and has nothing running between promotions. If you already operate one of these platforms and it serves you, Cascade is not trying to replace it. Cascade is for teams who would rather stay inside GitHub Actions than take on a separate runtime.
### CI-as-code generators
These tools generate or run CI configuration so you do not hand-write it.
-| Tool | What it does well | How cascade differs |
+| Tool | What it does well | How Cascade differs |
|---|---|---|
-| [projen](https://projen.io/) | Generates and continuously manages project config (including CI) from code. | cascade generates a narrow, promotion-focused set of workflows rather than managing whole-project config, and reads a manifest rather than a program. |
-| [Dagger](https://dagger.io/) | Portable pipelines as code, executed by a custom engine. | cascade emits plain Actions YAML that runs on stock GitHub runners, with no engine to run. |
-| [Earthly](https://earthly.dev/) | Repeatable, containerized build definitions. | cascade does not define builds; it orchestrates and promotes the builds your callbacks define. |
+| [projen](https://projen.io/) | Generates and continuously manages project config (including CI) from code. | Cascade generates a narrow, promotion-focused set of workflows rather than managing whole-project config, and reads a manifest rather than a program. |
+| [Dagger](https://dagger.io/) | Portable pipelines as code, executed by a custom engine. | Cascade emits plain Actions YAML that runs on stock GitHub runners, with no engine to run. |
+| [Earthly](https://earthly.dev/) | Repeatable, containerized build definitions. | Cascade does not define builds; it orchestrates and promotes the builds your callbacks define. |
-cascade overlaps with these on "do not hand-write your CI," but its goal is narrower and more opinionated: it models multi-environment promotion specifically, and its output is GitHub-native rather than a custom runtime or general scaffolding.
+Cascade overlaps with these on "do not hand-write your CI," but its goal is narrower and more opinionated: it models multi-environment promotion specifically, and its output is GitHub-native rather than a custom runtime or general scaffolding.
-## What cascade generates
+## What you get
-cascade emits ordinary GitHub Actions YAML and standard GitHub objects. As of today, a generated pipeline includes:
+Cascade emits ordinary GitHub Actions YAML and standard GitHub objects. A generated pipeline includes:
-- **Orchestrate, promote, release, and rollback workflows** that move a single artifact through your environments, pinned to a specific SHA and never rebuilt per stage.
+- **Orchestrate, promote, hotfix, and rollback workflows** that move a single artifact through your environments, pinned to a specific SHA and never rebuilt per stage.
- **GitHub Releases**, including release-asset upload and the release lifecycle (draft, prerelease, published) with release-candidate tag cleanup.
-- **Merge queue** configuration on the trunk integration path.
+- **Merge queue** configuration on the trunk integration path, when you opt in.
- **Concurrency** blocks so overlapping runs do not collide.
-- **A GitHub Environment gate**, threaded to your deploy callback as the `environment` input. (Because every deploy is a reusable-workflow caller job, the actual `environment:` declaration lives inside the workflow you point cascade at; see the [Callback Contract](/cascade/callback-contract/).)
+- **A GitHub Environment gate**, threaded to your deploy callback as the `environment` input. Because every deploy is a reusable-workflow caller job, the actual `environment:` declaration lives inside the workflow you point Cascade at.
- **Run summaries** via `$GITHUB_STEP_SUMMARY` for plan and preview output.
-- **Top-level `GITHUB_TOKEN` permission scoping** on the generated workflows.
+- **Top-level `GITHUB_TOKEN` permission scoping**, plus least-privilege per-callback `permissions:` blocks, on the generated workflows.
-What cascade does not generate is just as important. cascade does not build or publish your artifacts, does not run your deployments, and does not own any runtime path to production. Those are your callbacks. For the exact inputs and outputs cascade exchanges with your workflows, see the [Callback Contract](/cascade/callback-contract/); for the full design and ownership boundary, see [Architecture](/cascade/architecture/).
+What Cascade does not generate is just as important. Cascade does not build or publish your artifacts, does not run your deployments, and does not own any runtime path to production. Those are your callbacks. For the exact file set and per-workflow anatomy, see [Generated workflows reference](/cascade/reference/generated-workflows/); for the exact inputs and outputs Cascade exchanges with your workflows, see the [callback contract](/cascade/reference/callbacks/); for the full design and ownership boundary, see [Architecture](/cascade/internals/architecture/).
+
+---
+
+**Prerequisite:** none, this is the entry point.
+**Next:** [How Cascade works](/cascade/start/how-it-works/) for the mental model.
diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md
deleted file mode 100644
index 08ab54af..00000000
--- a/docs/src/content/docs/workflows.md
+++ /dev/null
@@ -1,494 +0,0 @@
----
-title: Workflows
-description: Internals of the orchestrate and promote workflows that cascade emits, including flow diagrams, inputs, outputs, change detection, and the hotfix mechanism.
----
-
-The framework generates two reusable workflows from your manifest: **Orchestrate** and **Promote**. Both are written by `cascade generate-workflow`.
-
-## Orchestrate
-
-Triggered on every merge to trunk. Handles the full CI/CD pipeline for the first environment in the promotion chain.
-
-### Flow
-
-```mermaid
-flowchart TD
- M["Merge to trunk"] --> S["Setup"] --> V["Validate"] --> B["Build"] --> D["Deploy"] --> F["Finalize"]
- S -.-> sn["Parse config, detect changes, compute version"]
- V -.-> vn["Optional pre-build validation"]
- B -.-> bn["Matrix: triggered builds only"]
- D -.-> dn["Matrix: triggered deploys, dependency-ordered"]
- F -.-> fn["Update state, generate changelog, draft pre-release"]
-
- classDef note fill:none,stroke:none,color:#8A929C;
- class sn,vn,bn,dn,fn note;
-```
-
-### Triggering
-
-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:
-
-```yaml
-# .github/workflows/orchestrate.yaml (generated)
-on:
- push:
- branches: [master] # taken from config.trunk_branch
-```
-
-### Standard Inputs
-
-The orchestrate workflow has no manual inputs by default. It runs automatically on push.
-
-### Outputs
-
-| Output | Description |
-|--------|-------------|
-| `deployed_sha` | Deployed commit SHA |
-| `triggered_builds` | JSON array of triggered builds |
-| `triggered_deploys` | JSON array of triggered deploys |
-| `version` | Calculated RC version (e.g., `v1.2.0-rc.0`) |
-| `changelog` | Generated changelog markdown |
-| `release_url` | URL to the GitHub release |
-| `execution_plan` | JSON execution plan with waves |
-
-### Change Detection
-
-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:
-```json
-{
- "triggered_builds": ["app"],
- "triggered_deploys": ["cdk", "services"],
- "has_changes": true,
- "execution_plan": {
- "waves": [
- {"name": "wave-1", "callbacks": ["app", "cdk"]},
- {"name": "wave-2", "callbacks": ["services"]}
- ]
- }
-}
-```
-
-### Version Calculation
-
-The version is computed from conventional commits between the previous release and the current SHA:
-
-| Commits since last release | Bump |
-|---------------------------|------|
-| `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.
-
-## Promote
-
-Manual workflow to promote between environments.
-
-### Flow
-
-```mermaid
-flowchart TD
- M["Default mode (one step at a time)"] --> P["Preflight"] --> D["Deploy"] --> Pub["Publish"] --> F["Finalize"]
- P -.-> pn["Validate source/target, check ancestry, gate breaking changes"]
- D -.-> dn["Matrix: per-deploy with change detection"]
- Pub -.-> pubn["Only at prerelease to release boundary, if publish: configured"]
- F -.-> fn["Update state, publish release, 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.
-
-### Triggering (Generated)
-
-```yaml
-# .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
-```
-
-### Inputs
-
-| Input | Type | Default | Description |
-|-------|------|---------|-------------|
-| `mode` | choice | `default` | `default` or a cascade target (e.g., `dev-to-prod`) |
-| `force` | boolean | false | Continue on failure (default mode only) |
-| `allow_breaking_changes` | boolean | false | Required to cross the prerelease->release boundary with breaking changes |
-| `dry_run` | boolean | false | Preview without deploying |
-| `deploys` | string | `all` | Comma-separated deploy names or `all` |
-| `rollback_on_failure` | boolean | true | Atomic semantics: revert on failure |
-
-### Outputs
-
-| Output | Description |
-|--------|-------------|
-| `source_sha` | SHA being promoted |
-| `target_env` | Destination environment |
-| `rollback_sha` | SHA to revert to on failure |
-| `deploys_to_run` | JSON array of deploys to run |
-| `external_deploys_to_run` | JSON array of external deploys to run |
-| `version` | Version applied to the target |
-| `changelog` | Changelog since the previous release |
-| `release_url` | URL to the GitHub release |
-
-### Atomic Promotions with Rollback
-
-The promote workflow can run atomic promotions. If any deploy fails, the deploys that already succeeded are rolled back:
-
-```yaml
-# 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:
-```yaml
-rollback_on_failure: false
-```
-
-### Selective Deployments
-
-Use the `deploys` input to promote specific deploys:
-
-```yaml
-deploys: "app,infra" # Only promote app and infra
-deploys: "all" # Promote all (default)
-```
-
-### Per-Deployable Change Detection
-
-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).
-
-### Promotion Modes
-
-The mode dropdown is generated from the configured `environments` list. The env names and the resulting `-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-test` | Promote dev -> test |
-| `dev-to-uat` | Cascade dev -> test -> uat (each step deployed and finalized) |
-| `dev-to-prod` | Full cascade through all environments + release |
-| `uat-to-prod` | Partial cascade from uat onward |
-| `test-to-prod` | Standard 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.
-
-### Publish Step
-
-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=
-old_version=
-new_version=
-sha=
-artifact_id=
-```
-
-The publish callback is responsible for the registry operation (retag, copy, sign).
-
-### Version Determination
-
-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.
-
-## Hotfix
-
-```mermaid
-flowchart TD
- RF["Roll forward first (default) fix merged to trunk; refused if not an ancestor of trunk tip"]
- RF -- "env must run base + fix only" --> IB["env/<env> integration branch created on demand at recorded state SHA"]
- IB --> CP["cherry-pick onto hotfix/<env>/<short-sha>"]
- CP -- "clean" --> PRclean["resolution PR · cascade-hotfix state_token merge, gated by env checks"]
- CP -- "conflict" --> PRconf["resolution PR · cascade-hotfix-conflict markers committed; human force-pushes head"]
- PRclean --> MERGE["on merge"]
- PRconf --> MERGE
- MERGE --> FIN["build -> deploy one env -> finalize vX.Y.Z-rc.N.hotfix.M · ref env/<env> · patches [fixes]"]
- FIN --> DIV["environment diverged other environments untouched"]
- DIV == "promote a trunk SHA containing the fix patch-containment guard refuses dropping it" ==> REJOIN["rejoin trunk divergence cleared · env/<env> 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.
-
-### Roll forward on trunk first (the default)
-
-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.
-
-### Per-environment integration branches
-
-When an environment genuinely needs to diverge, the hotfix is staged on a per-environment integration branch named `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//` whose base is `env/`, and a resolution pull request is opened with base `env/`.
-
-While an environment is diverged its state carries three additional fields, all additive and absent for environments that track trunk:
-
-```yaml
-state:
- test:
- sha: # 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: # the trunk anchor of the divergence
- patches: [, ...] # trunk commits applied on top
-```
-
-`cascade status` surfaces `ref`, `base_sha`, and `patches` only when they are set.
-
-### Reconciling a stale env branch
-
-Before staging the cherry-pick, the hotfix plan reconciles the `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/` 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/`) 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/` 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.
-
-### Elevating across the chain
-
-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.
-
-### Cherry-pick and resolution pull request
-
-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/` 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//
-# 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:
-Cascade-Hotfix-Base:
-```
-
-When a conflict is resolved by hand, the resolution on `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/` back to trunk is wrong because it would introduce merge commits into a history that every SHA comparison in cascade assumes moves forward.
-
-### Rejoin and cleanup
-
-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/` 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.
-
-### Generated `cascade-hotfix.yaml` workflow
-
-`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:
-
-| Job | Trigger | Role |
-| --- | --- | --- |
-| plan | dispatch | Fetch env branches and tags, run `cascade hotfix plan`, surface branch-protection suggestions as `::notice::` lines |
-| apply | dispatch (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 |
-| check | open pull request to `env/*` | Validate the manifest while the hotfix pull request is open |
-| build | merged hotfix | Build the merge SHA, since a cherry-picked commit has no prebuilt artifact |
-| deploy | merged hotfix | Deploy to the target environment, paired with a rollback job mirroring the promote workflow |
-| finalize | all deploys succeed | Run `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](/cli-reference/#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.
-
-## Rollback
-
-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`).
-
-### External-signal trigger
-
-To let an external system (an alerting or incident pipeline) drive the same rollback automatically, opt into a `repository_dispatch` trigger:
-
-```yaml
-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:
-
-```yaml
-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` key | Rollback parameter | Meaning |
-| --- | --- | --- |
-| `environment` | environment | Environment to roll back (required) |
-| `target` | target | Prior version or SHA; omit for the previous version (N-1) |
-| `deployable` | deployable | Limit the rollback to one deployable; omit for the whole environment |
-| `dry_run` | dry_run | When `"true"`, resolve and print without deploying |
-
-An external system fires the rollback with a single dispatches API call (substitute your own org and repo):
-
-```bash
-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.
-
-## Reconcile companion
-
-Set `reconcile.enabled: true` (see [Reconcile companion](/configuration/#reconcile-companion-opt-in) in the configuration reference) and cascade emits an opt-in, fork-safe lane that watches for an external governed action-pin change and adopts it back into the manifest.
-
-`cascade-reconcile-check.yaml` is the detector: a `pull_request` job that runs with `contents: read` only, so a fork pull request gets a read-only token and no secrets. It runs the real `cascade reconcile --check` command against the pull request's changed workflow files, which decides relevance and writes the changed governed refs to a data-only `pin-reconcile-result` artifact. It never pushes or comments.
-
-`cascade-reconcile-companion.yaml` is the base-definition companion: an `on: workflow_run` job that fires once the detector completes, running in the base repository's context with a scoped `contents: write` / `pull-requests: write` token rather than whatever posture the (possibly fork) pull request carries. Its steps:
-
-1. **Trusted PR resolution.** The target pull request is derived only from the triggering `workflow_run`'s own metadata (its `pull_requests` array, or a head-SHA lookup for a fork pull request), then re-fetched fresh from the API. The companion never trusts the detector's artifact for the pull request number, and it aborts rather than reconciling stale data if the pull request's head has moved since the source run started.
-2. **Relevance trigger.** The companion downloads the detector's artifact as data and no-ops when it reports no governed change, so an irrelevant pull request costs nothing beyond the read-only detector.
-3. **Head-as-data checkout.** The pull request's head is fetched via the trusted `refs/pull//head` ref on the base repository, never a direct checkout of a fork's own repository, so nothing from a fork's own configuration is ever executed.
-4. **Pinned-binary execution.** The companion installs a pinned release build of the cascade CLI (the same `setup-cli` action every generated workflow uses) rather than building or running off the repository's own source, so a pull request cannot smuggle in a modified reconcile implementation.
-5. **Real, idempotent adoption.** It runs the actual `cascade reconcile` command against the changed files, the same command a maintainer could run by hand, so a converged tree is a real no-op rather than a scripted approximation.
-6. **Commit routing.** `commit: append` (the default) pushes the adoption commit directly onto the pull request's own branch, but only when that pull request is not a fork; a fork pull request always falls back to a sticky comment naming the refs to adopt by hand, since cascade has no push access to a fork's branch. `commit: followup` never touches the original branch at all: it commits to a cascade-owned `cascade-reconcile/pr-` branch and opens (or updates) a separate pull request against the same base, which is the recommended posture for a repository that automerges once checks pass.
-
-Three loop-termination guards keep the companion from ever looping on itself: it pushes only when the real reconcile command actually changed something (a converged tree pushes nothing), it re-checks the branch's fresh tip immediately before pushing and aborts rather than overwriting commits made since the run started, and it never force-pushes onto a branch it does not own.
-
-**Token requirement.** The common case needs only `Contents: write` on the token that pushes the adoption commit, because the triggering pull request already updated the generated workflow byte for byte and only the manifest's `action_pins` entry changes underneath it. `Workflows: write` is needed only when a regenerate must also push updated `.github/workflows/*.yaml` files, which is the same token headroom the [Action pinning](/configuration/#action-pinning) section describes.
-
-**Honest automerge caveat.** Enabling this companion turns what would have been a red drift check into a green one: an external pin bump that used to require a human to intervene is instead adopted and pushed automatically. A repository that automerges once checks pass can therefore merge a pin bump unattended. Prefer `commit: followup` if that matters to you; it opens the adoption as its own pull request so a human still reviews the change before it merges.
-
-## Workflow Permissions
-
-Generated workflows include the necessary permissions:
-
-```yaml
-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:
-
-```yaml
-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.
-
-## Concurrency Control
-
-Each workflow uses concurrency groups to prevent conflicts:
-
-```yaml
-# 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
-```
-
-## Dry Run Mode
-
-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.
-
-## Debugging
-
-Enable trace-level logging by setting `TRACE=true` in the environment, or invoke the CLI with `--trace`:
-
-```bash
-cascade --trace orchestrate setup --environment dev
-```
-
-Trace logs include:
-- Full change detection results
-- Dependency resolution steps
-- Callback input/output details
-- State operations