diff --git a/README.md b/README.md index 3237b255..f2e31659 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index a7b185c7..42200afa 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -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: [ diff --git a/docs/src/content/docs/guides/components.md b/docs/src/content/docs/guides/components.md new file mode 100644 index 00000000..1f94dc53 --- /dev/null +++ b/docs/src/content/docs/guides/components.md @@ -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..`, carrying + the full per-environment record (version, SHA, deploy history, and the rollback + ring) for each component independently. +- **Hotfix branches** are namespaced as `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//` 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. diff --git a/docs/src/content/docs/reference/manifest.md b/docs/src/content/docs/reference/manifest.md index 7a29648b..a5e88841 100644 --- a/docs/src/content/docs/reference/manifest.md +++ b/docs/src/content/docs/reference/manifest.md @@ -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..`), and tag +namespaces are covered in [Per-component +versioning](/cascade/reference/versioning/#per-component-versioning). + ## Shared policy and pattern reference ### Policy fields @@ -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) diff --git a/docs/src/content/docs/reference/versioning.md b/docs/src/content/docs/reference/versioning.md index 56a35e99..3a31c6fc 100644 --- a/docs/src/content/docs/reference/versioning.md +++ b/docs/src/content/docs/reference/versioning.md @@ -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..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. @@ -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..` and carries the full environment state (version, +SHA, deploy history, and the rollback ring). Per-component published releases are +recorded under `latest_release.components.`. Single-component state stays at +the top-level `state.` 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. diff --git a/docs/src/content/docs/start/how-it-works.md b/docs/src/content/docs/start/how-it-works.md index c93c5793..8668076a 100644 --- a/docs/src/content/docs/start/how-it-works.md +++ b/docs/src/content/docs/start/how-it-works.md @@ -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/).