Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ Opt-in companions (drift-check, PR-preview, pin-reconcile) are emitted only when
| 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. |
| Multi-component repos | A `components:` block versions, promotes, hotfixes, and rolls back several independent components from one manifest, each in its own tag and state namespace. Omit it for a single-component repo and output is byte-identical. |

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.

Expand Down
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export default defineConfig({
{ 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: 'Split a repo into components', link: '/guides/components/' },
{ label: 'Visualize the pipeline', link: '/guides/visualize/' },
]},
{ label: 'Reference', items: [
Expand Down
181 changes: 181 additions & 0 deletions docs/src/content/docs/guides/components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
---
title: Split a repo into components
description: Version, promote, hotfix, and roll back several independent components from one manifest, each in its own isolated tag and state namespace.
---

A single repository often holds more than one deliverable: an API and a web
frontend, a service and its infrastructure module, a family of libraries. Cascade
lets each of these be a **component** with its own version line, its own promotion
cadence, and its own hotfix and rollback history, all described in one manifest.

Components are the native shape of `schema_version: 1`. A manifest with no
`components:` block is one implicit component that spans the whole repository, and
it generates byte-identical output to a single-component setup. Nothing about the
single-component path changes, and there is nothing to migrate. You reach for
`components:` only when you want more than one independently versioned unit in the
same repository.

:::note[Components are not multiple repos]
This guide is about several components inside **one** repository. To coordinate
artifacts that live in **separate** repositories, each with its own history and
release cadence, see [Coordinate multiple repos](/cascade/guides/multi-repo/). The
two models solve different problems and can be combined.
:::

## What a component is

A component is a named subtree of the repository that versions and ships on its
own. Each component declares two required fields:

- `path`, the subtree the component owns (for example `api/` or `web/`).
- `tag_prefix`, the prefix for that component's version tags. Each component needs
a distinct prefix so their tag namespaces never overlap.

Everything else a component needs, such as its builds, deploys, environment
ladder, or breaking-change policy, is inherited from the shared top-level config
and overridden per component only where it differs.

## Declare components with shared defaults

When a `components:` block is present, the top-level config becomes the set of
**shared defaults** every component inherits. Each entry under `components:`
overrides those defaults where it sets a value and inherits them everywhere else.

This manifest ships an `api` service and a `web` frontend from one repository. The
shared defaults set the CLI pin, the trunk branch, and the default environment
ladder. Each component owns its subtree, its tag namespace, and its own build and
deploy:

```yaml
# .github/manifest.yaml
ci:
config:
schema_version: 1
trunk_branch: main
cli_version: v0.9.1
environments: [dev, staging, prod]

components:
api:
path: api/
tag_prefix: api-
builds:
- name: api
workflow: .github/workflows/build-api.yaml
triggers: ["api/**"]
deploys:
- name: api
workflow: .github/workflows/deploy-api.yaml
triggers: ["api/**"]

web:
path: web/
tag_prefix: web-
# web ships to a shorter ladder than the shared default.
environments: [dev, prod]
builds:
- name: web
workflow: .github/workflows/build-web.yaml
triggers: ["web/**"]
deploys:
- name: web
workflow: .github/workflows/deploy-web.yaml
triggers: ["web/**"]
```

Here `api` inherits the shared `[dev, staging, prod]` ladder, while `web`
overrides it with a shorter `[dev, prod]` ladder. Both inherit `trunk_branch` and
`cli_version` because those are shared, repository-wide settings that a component
cannot override. For the exact list of which fields a component may override and
which stay repository-wide, see the [`components` field
reference](/cascade/reference/manifest/#components).

## How each component versions independently

Every component computes its own version. Cascade scopes the commit walk to the
component's `path`, so only changes under `api/` bump the `api` version and only
changes under `web/` bump the `web` version. Each component reads and writes tags
under its own `tag_prefix`, and that namespace is strict: `api-1.2.3` and
`web-1.2.3` never cross-match, and a prefix that is a substring of another (such as
`api-` against `api-beta-`) cannot collide either.

The result is two independent version lines from one trunk. A commit that touches
only `web/` advances `web-` tags and leaves the `api-` line untouched.

The implicit single-component default keeps the permissive historical tag parsing,
so a repository without a `components:` block reads its tags exactly as before. See
[Per-component versioning](/cascade/reference/versioning/#per-component-versioning)
for the tag-namespace rules in full.

## How each component promotes independently

Each component gets its own promote workflow and its own concurrency lane. Cascade
derives a per-component concurrency group so two components never serialize against
each other: promoting `api` from `dev` to `staging` does not queue behind or cancel
a `web` promotion. You promote each component on its own schedule, and the release
boundary (the prerelease and release markers near the top of each ladder) is
evaluated per component against that component's own environment list.

Because `web` above declares a two-environment ladder, its promotion chain has a
single step, while `api` promotes across three environments. Each component's
ladder is its own.

## How hotfix and rollback stay isolated

Hotfix and rollback operate per component, in separate namespaces, so an operation
on one component never reaches into another:

- **State** is recorded per component at `state.components.<name>.<env>`, carrying
the full per-environment record (version, SHA, deploy history, and the rollback
ring) for each component independently.
- **Hotfix branches** are namespaced as `env/<component>/<env>`, so a hotfix in
flight on `api` in `staging` lives on its own branch and its single-flight guard
only counts that component's open hotfix pull requests. If you protect these
branches with a ruleset, match the nested `env/<component>/<env>` pattern.
- **Rollback** reads the deploy history from that component's own state ring and
enumerates that component's own environment ladder, so the rollback dropdown for
`web` offers only `web` environments.
- **Release-candidate cleanup** runs within the component's tag namespace.
Publishing `api-1.0.1` reaps only `api-` prerelease tags and never touches
`web-` tags.

The operator walkthroughs in [Run a hotfix](/cascade/guides/hotfix/) and [Roll
back an environment](/cascade/guides/rollback/) apply per component unchanged; the
only difference is that the branch, state, and tag names carry the component name.

## Target a subset of the ladder per component

A component does not have to ride the full shared ladder. Override `environments:`
on the component to give it a shorter chain, as `web` does above with `[dev,
prod]`. The shared default stays whatever the top-level `environments` list
declares, and each component narrows it where that makes sense. A component's
release and prerelease markers are the last and second-to-last entries of its own
resolved ladder.

## The single-component default is unchanged

If you never write a `components:` block, none of the above applies. The whole
repository is one unit, the top-level `tag_prefix` is the one tag namespace, the
top-level `environments` is the one ladder, and the generated workflows, state
serialization, version lines, and tags are byte-identical to a pre-component
setup. Components are strictly additive: adopt them when you have a second thing to
version, and ignore them entirely until then.

## What components deliberately do not do

Components are independent by design. Cascade does not sequence one component after
another, build a cross-component dependency graph, or add a fan-in barrier across
components. When you need one unit to react to another, use the `external` and
`notify` coordination path described in [Coordinate multiple
repos](/cascade/guides/multi-repo/), which works across components and repositories
alike.

## Wayfinding

**Prerequisite:** [How Cascade works](/cascade/start/how-it-works/) for the trunk,
environment chain, and release boundary each component follows.

**Reference:** the [`components` field
reference](/cascade/reference/manifest/#components) for the override matrix and
[Per-component versioning](/cascade/reference/versioning/#per-component-versioning)
for the tag-namespace and state rules.
81 changes: 80 additions & 1 deletion docs/src/content/docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,85 @@ The check validates cascade's own configuration only, requests `contents: read`

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.

## components

A `components:` block declares several independently versioned components in one
repository. When it is present, the top-level config becomes the shared default
set every component inherits, and each entry overrides those defaults where it
sets a value. A manifest with no `components:` block is one implicit component
spanning the whole repository and generates byte-identical output, so components
are strictly additive. See [Split a repo into
components](/cascade/guides/components/) for the operator walkthrough.

```yaml
ci:
config:
environments: [dev, staging, prod]
components:
api:
path: api/
tag_prefix: api-
builds:
- name: api
workflow: .github/workflows/build-api.yaml
triggers: ["api/**"]
web:
path: web/
tag_prefix: web-
environments: [dev, prod]
```

`components` is a map keyed by component name. Each name must be identifier-safe
(letters, digits, and underscores). Each entry accepts two required fields plus
any inheritable field it overrides.

| Field | Status | Type | Required | Description |
|-------|--------|------|----------|-------------|
| `path` | emitted (behavior) | string | Yes | The subtree this component owns. Relative, with no `..` segments. Scopes the component's version commit walk and its default push-paths trigger. |
| `tag_prefix` | emitted (behavior) | string | Yes | The component's version-tag prefix. Must be distinct from every other component's prefix so their tag namespaces never collide. |

### Inheritable overrides

A component inherits every shared top-level field and may override the ones below
where an override is meaningful. An unset field takes the shared top-level value.

`tag_grammar`, `environments`, `release_trigger`, `allow_breaking_changes`,
`validate`, `builds`, `deploys`, `publish`, `external`, `notify`, `release`,
`changelog`, `runs_on`, `job_timeout_minutes`, `dispatch_inputs`,
`extra_triggers`, `pr_preview`, `validate_check`, `rollback`, `deployments`,
`environment_config`, `triggers`, `release_token`, and `release_token_app`.

`concurrency.cancel_in_progress` is inheritable, but `concurrency.group` is not:
the orchestrate, promote, and rollback groups are derived per component so runs
never serialize across components. Setting a component `concurrency.group` is a
parse error.

### Repository-wide fields

These fields are set once at the top level and cannot be overridden per component,
because they describe the repository or the single writer of shared state rather
than one component: `schema_version`, `trunk_branch`, `cli_version`,
`cli_version_sha`, `state_token`, `state_token_app`, `manifest_file`,
`manifest_key`, `action_folder`, `git`, `drift_check`, `reconcile`, `pin_mode`,
`action_pins`, `telemetry`, and `merge_queue`. Setting any of them under a
component is a parse error, as is any unknown field.

### Validation rules

`cascade parse-config` rejects a `components:` block that breaks isolation:

- Each component must set `path` (relative, no `..`) and `tag_prefix`.
- Component names must be identifier-safe.
- Two components must not share a `tag_prefix`; a collision is a parse error, not
a silently shared namespace.
- A top-level `concurrency.group` must not be set when components are declared,
and a component may not set its own `concurrency.group`.
- A repository-wide field or an unknown field set under a component is rejected.

Per-component versioning, state (`state.components.<name>.<env>`), and tag
namespaces are covered in [Per-component
versioning](/cascade/reference/versioning/#per-component-versioning).

## Shared policy and pattern reference

### Policy fields
Expand Down Expand Up @@ -834,7 +913,7 @@ Reserved fields parse but have zero generator consumption today. They reserve a
| `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/).
The full reserved-shapes catalog, including the canary sub-fields (`steps`, `analysis`, `percent`, `bake_time`, `promote_callback`, `rollback_callback`), lives in [Versioning and schema](/cascade/reference/versioning/).

## State section (managed)

Expand Down
43 changes: 33 additions & 10 deletions docs/src/content/docs/reference/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,6 @@ A CLI supports the current schema version and the immediately preceding one (N-1

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`.

### 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.<env>.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.

Component names must be job-ID-safe (letters, digits, hyphens, underscores), and a configured `path` must be relative with no `..` segments.

### Progressive rollout: canary and blue/green

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.
Expand Down Expand Up @@ -118,6 +108,39 @@ Two fields on `rollout:` are live today, not reserved. `rollout.fail_fast` and `

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.

## Per-component versioning

A manifest may declare several independently versioned components under a
top-level `components:` map (see [Split a repo into
components](/cascade/guides/components/) for the walkthrough and the [`components`
field reference](/cascade/reference/manifest/#components) for the field surface).
Each component versions in its own namespace, and the single-component default is
unchanged.

**Path-scoped version computation.** Cascade scopes each component's commit walk
to its `path`, so only changes under that subtree bump that component's version.
Two components version on separate lines from the same trunk.

**Strict per-component tag namespace.** Each component reads and emits tags under
its own `tag_prefix`, parsed strictly. `svc-1.2.3` and `web-1.2.3` never
cross-match, and a prefix that is a substring of another (such as `svc-` against
`svc-beta-`) cannot collide. Release-candidate cleanup is scoped the same way, so
publishing one component's release reaps only that component's prerelease tags. A
component's resolved grammar still honors any inherited or overridden
[`tag_grammar`](/cascade/reference/manifest/#tag_grammar); the per-component prefix
is layered on top as a hard namespace boundary.

**The implicit default stays permissive.** A repository with no `components:`
block keeps the historical permissive prefix parsing (any alphabetic prefix on
read), so its tags read exactly as before and its output stays byte-identical.

**Per-component state.** Each component's per-environment record lives at
`state.components.<name>.<env>` and carries the full environment state (version,
SHA, deploy history, and the rollback ring). Per-component published releases are
recorded under `latest_release.components.<name>`. Single-component state stays at
the top-level `state.<env>` and `latest_release`, unchanged. Cascade manages both
shapes; do not hand-edit them.

## Migrations

Each `schema_version` bump is recorded with a `Migration` section in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md) describing exactly what changed and the steps to update a manifest from the previous version. There are no migrations yet: the current schema version is the first.
Expand Down
12 changes: 12 additions & 0 deletions docs/src/content/docs/start/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ A primary repo coordinates deploys in other repos through the external lane: whe

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/).

## One repo, several components

Everything above describes one unit moving through one chain. If a repository holds
more than one thing to ship, such as an API and a web frontend, a `components:`
block gives each its own version line, environment ladder, and promotion, hotfix,
and rollback history, all from the same trunk and the same manifest. Each component
runs the model on this page in its own isolated namespace.

Components are additive: a manifest with no `components:` block is a single implicit
component that spans the whole repository and behaves exactly as described here. See
[Split a repo into components](/cascade/guides/components/) for the walkthrough.

---

**Prerequisite:** [Why Cascade](/cascade/start/why-cascade/).
Expand Down