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
5 changes: 5 additions & 0 deletions docs/src/content/docs/guides/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ and overridden per component only where it differs.
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.
Overrides are a deep merge: a component that sets only part of a block, such as a
single `environment_config` entry or one `tag_grammar` field, keeps the inherited
siblings rather than dropping them. See the [override
matrix](/cascade/reference/manifest/#inheritable-overrides) for the precise
per-field rules.

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
Expand Down
30 changes: 30 additions & 0 deletions docs/src/content/docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,36 @@ where an override is meaningful. An unset field takes the shared top-level value
`extra_triggers`, `pr_preview`, `validate_check`, `rollback`, `deployments`,
`environment_config`, `triggers`, `release_token`, and `release_token_app`.

Inheritance is a **deep merge**. When a component overrides a block, it merges
field-by-field into the inherited default rather than replacing it wholesale, so
a partial override never drops the shared siblings it did not mention:

- A **nested block** merges recursively. A component that sets only
`tag_grammar.prefix` keeps the inherited `prerelease_token`, `prerelease_separator`,
and `dryrun_token`. A component that sets only `deployments.keep_prior_active`
keeps the inherited `deployments.enabled`.
- A **keyed map** such as `environment_config` merges by key, and the entry under
each key merges field-by-field. A component that configures only its `prod`
environment keeps the shared `dev` and `staging` entries, and a `prod` entry that
sets only `wait_timer` keeps the inherited `gha_environment` for `prod`.
- A **scalar or a list** replaces. A component `environments` list overrides the
shared list outright, and a scalar such as `release_trigger` overrides the shared
value. An explicit opt-out is honored: a component that sets an inherited boolean
back to `false` (for example `deployments.enabled: false` under a shared
`deployments.enabled: true`), or an inherited number to `0`, overrides the shared
value rather than inheriting it.
- A few **exclusive blocks whole-replace** instead of field-merging, because their
fields are not independently composable: `secrets` (either `inherit` or an
explicit allow-list, never both), `runs_on` (a label, a list, or a `{group,
labels}` object, one form only), and the `release_token_app` / `state_token_app`
credential blocks. A component that sets one of these replaces the inherited
block outright. This keeps a component narrowing `secrets: inherit` to an
explicit allow-list from being silently broadened back to inherit-all.

The one exception to replace-a-list is the additive path set: a component's
`extra_paths` unions with the top-level `shared_paths` rather than replacing it.
See [Shared paths](#shared-paths).

`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
Expand Down
88 changes: 88 additions & 0 deletions e2e/scenarios/57-component-partial-inherit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: "Per-Component Partial Override Inherits"
description: |
Proves uniform deep-merge inheritance for components end to end. The manifest
enables the GitHub Deployments integration once at the top level
(deployments.enabled: true) and lets one component set only a single sub-field of
that block (api sets deployments.keep_prior_active: true) while the other
component (web) carries no deployments override at all.

Under whole-replace inheritance a partial override dropped every inherited
sibling, so api's block would have collapsed to {keep_prior_active: true} with
enabled defaulting back to false: api's orchestrate workflow would have emitted
no deployment reporting at all. Under deep-merge api keeps the inherited
enabled: true and layers its keep_prior_active on top, so its finalize job
reports the deployment with auto_inactive=false, while web inherits the plain
enabled: true and reports with the default auto_inactive=true. The scenario
generates the per-component set, proves the roundtrip is drift-free, and asserts
each component's orchestrate workflow carries the correctly merged deployment
reporting rather than the sibling's.

config:
trunk_branch: main
environments: [dev, prod]
deployments:
enabled: true
builds:
- name: app
workflow: build.yaml
triggers: ["services/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["services/**"]
components:
api:
path: services/api
tag_prefix: api-
deployments:
keep_prior_active: true
web:
path: services/web
tag_prefix: web-

steps:
- name: "Seed both component subtrees"
action: commit
commit:
message: "seed component sources"
files:
services/api/main.go: |
package main

func main() {}
services/web/main.go: |
package main

func main() {}

- name: "Regenerate the per-component set and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0

- name: "Each component's deployment reporting reflects the merged block"
action: verify
verify:
regenerate: true
expect_exit: 0
# api set only keep_prior_active; it inherits the top-level enabled: true, so
# its orchestrate workflow still creates a deployment and, because
# keep_prior_active merged in, reports it with auto_inactive=false. web
# inherits the bare enabled: true and reports with the default
# auto_inactive=true. The not_contains cross-checks that neither component
# picked up the other's auto_inactive value.
expect:
workflow_files:
- path: ".github/workflows/orchestrate-api.yaml"
contains:
- "Create deployment"
- "auto_inactive=false"
not_contains:
- "auto_inactive=true"
- path: ".github/workflows/orchestrate-web.yaml"
contains:
- "Create deployment"
- "auto_inactive=true"
not_contains:
- "auto_inactive=false"
77 changes: 77 additions & 0 deletions e2e/scenarios/58-component-opt-out-bool.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: "Per-Component Boolean Opt-Out"
description: |
Proves the opt-out direction of uniform deep-merge inheritance end to end. The
manifest enables the GitHub Deployments integration once at the top level
(deployments.enabled: true). One component (api) sets deployments.enabled: false
to opt out, while the other (web) carries no override and inherits the shared
enabled: true.

The inheritable boolean is pointer-typed so an explicit false is distinct from
unset: a component that opts out marshals its false into the merge and overrides
the inherited true rather than dropping to nothing and silently staying enabled.
So api's orchestrate workflow emits no deployment reporting at all, while web's
still reports the deployment. The scenario generates the per-component set,
proves the roundtrip is drift-free, and asserts the opt-out component carries no
deployment step while its sibling does.

config:
trunk_branch: main
environments: [dev, prod]
deployments:
enabled: true
builds:
- name: app
workflow: build.yaml
triggers: ["services/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["services/**"]
components:
api:
path: services/api
tag_prefix: api-
deployments:
enabled: false
web:
path: services/web
tag_prefix: web-

steps:
- name: "Seed both component subtrees"
action: commit
commit:
message: "seed component sources"
files:
services/api/main.go: |
package main

func main() {}
services/web/main.go: |
package main

func main() {}

- name: "Regenerate the per-component set and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0

- name: "The opt-out component drops deployment reporting; the sibling keeps it"
action: verify
verify:
regenerate: true
expect_exit: 0
# api set deployments.enabled: false, overriding the inherited true, so its
# orchestrate workflow creates no deployment. web inherited the shared true, so
# its workflow still creates one. This is the opt-out direction the pointer-
# typed inheritable fields make correct.
expect:
workflow_files:
- path: ".github/workflows/orchestrate-api.yaml"
not_contains:
- "Create deployment"
- path: ".github/workflows/orchestrate-web.yaml"
contains:
- "Create deployment"
82 changes: 82 additions & 0 deletions e2e/scenarios/59-component-secrets-replace-leaf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: "Per-Component Secrets Replace-Leaf"
description: |
Proves the secrets block is a replace-leaf in the inheritance deep merge,
end to end. The shared validate gate opts into inheriting all caller secrets
(validate.secrets: inherit). One component (api) narrows to an explicit
allow-list (validate.secrets: {NPM_TOKEN: NPM_TOKEN}); the other (web) inherits.

The secrets block is XOR-exclusive: inherit-all OR an explicit allow-list, never
both. A field-merge of the inherited inherit with the component allow-list would
set both, and generation gives inherit precedence, silently broadening api back
to inherit-all and discarding its least-privilege allow-list. Treating secrets
as a replace-leaf keeps the component's allow-list intact. The scenario generates
the per-component set, proves the roundtrip is drift-free, and asserts api's
orchestrate workflow emits the explicit NPM_TOKEN mapping (not secrets: inherit)
while web inherits secrets: inherit.

config:
trunk_branch: main
environments: [dev, prod]
validate:
workflow: validate.yaml
secrets: inherit
builds:
- name: app
workflow: build.yaml
triggers: ["services/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["services/**"]
components:
api:
path: services/api
tag_prefix: api-
validate:
workflow: validate.yaml
secrets:
NPM_TOKEN: NPM_TOKEN
web:
path: services/web
tag_prefix: web-

steps:
- name: "Seed both component subtrees"
action: commit
commit:
message: "seed component sources"
files:
services/api/main.go: |
package main

func main() {}
services/web/main.go: |
package main

func main() {}

- name: "Regenerate the per-component set and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0

- name: "The narrowing component keeps its allow-list; the sibling inherits inherit"
action: verify
verify:
regenerate: true
expect_exit: 0
# api narrowed validate.secrets to an explicit allow-list, so its orchestrate
# workflow emits the NPM_TOKEN mapping and never secrets: inherit. web inherited
# the shared inherit-all block. A field-merge would have broadened api back to
# secrets: inherit, discarding its least-privilege scope.
expect:
workflow_files:
- path: ".github/workflows/orchestrate-api.yaml"
contains:
- "NPM_TOKEN: ${{ secrets.NPM_TOKEN }}"
not_contains:
- "secrets: inherit"
- path: ".github/workflows/orchestrate-web.yaml"
contains:
- "secrets: inherit"
Loading