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 CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Public APIs follow a functional-options style: required inputs are positional an
cascade holds to a few conventions in its own codebase and in the workflows it generates:

- **Additive manifest changes**: new fields are always optional with sensible defaults, so existing manifest files keep working across minor version bumps.
- **Path fields reach every path sink**: a manifest field that widens which files a component reacts to must thread through all three places a path is consumed, or it is a silent bug. The emitted `on: push` paths filter fires the workflow, per-callback change detection decides which builds and deploys run, and the version commit range decides the bump. A field that reaches only some of these triggers a run that then no-ops, or bumps a version whose builds skip as unchanged. When you add such a field, add a test that asserts the shared path reaches each sink.
- **Callback isolation**: generated workflows call your workflows via `workflow_call`, and cascade never reaches into your callback logic.
- **Metadata courier**: cascade passes artifact identifiers and versions between stages. It never touches your container registry, package registry, or the systems you deploy to directly.

Expand Down
6 changes: 6 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@
"items": { "type": "string" },
"description": "Global path patterns for the orchestrate workflow paths filter. When set, these are used exclusively instead of per-callback triggers."
},
"shared_paths": {
"type": "array",
"items": { "type": "string" },
"description": "Repo-relative globs every component depends on, such as a shared library or a root build file. Each is fanned into every component's effective path set, so a commit touching only a shared path fires each component's orchestrate workflow and counts toward each component's version bump. Sugar for repeating the same glob in every component's extra_paths; applies only when a components block is present."
},
"release_trigger": {
"type": "string",
"enum": ["push", "dispatch"],
Expand Down Expand Up @@ -548,6 +553,7 @@
"deployments": { "$ref": "#/definitions/deploymentsConfig" },
"environment_config": { "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Overrides the shared per-environment settings for this component." },
"triggers": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared orchestrate path filter for this component." },
"extra_paths": { "type": "array", "items": { "type": "string" }, "description": "Repo-relative globs beyond this component's own path that both fire its orchestrate workflow and count toward its version bump, so a change to a shared dependency this component consumes bumps it correctly. Additive to path and to any top-level shared_paths." },
"release_token": { "type": "string", "description": "Overrides the shared release-operations token expression for this component." },
"release_token_app": { "$ref": "#/definitions/appTokenSource" }
}
Expand Down
35 changes: 35 additions & 0 deletions docs/src/content/docs/guides/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,41 @@ so a repository without a `components:` block reads its tags exactly as before.
[Per-component versioning](/cascade/reference/versioning/#per-component-versioning)
for the tag-namespace rules in full.

## Share code across components

Scoping the commit walk to each component's `path` is the isolation invariant, but
a monorepo also has code every component shares: a common library, a proto package,
a root build file. A change there sits outside every component's `path`, so on its
own it fires nothing and bumps nothing. Two additive fields let a component opt into
the shared code it depends on:

```yaml
ci:
config:
environments: [dev, prod]
shared_paths:
- libs/common/** # every component depends on this
components:
api:
path: services/api
tag_prefix: api-
extra_paths:
- libs/proto/** # only api depends on the proto package
web:
path: services/web
tag_prefix: web-
```

`extra_paths` widens one component's scope; top-level `shared_paths` widens every
component's scope and is sugar for adding the same glob to each component's
`extra_paths`. A component's effective scope is its `path` plus both. That scope
reaches every place a path matters: the workflow's `push` filter fires on a shared
change, change detection runs the affected builds and deploys, and the version walk
counts the shared commit. A breaking (`feat!:`) commit under `libs/common/` bumps
both `api` and `web`; a breaking commit under `libs/proto/` bumps only `api`. When
you declare neither field, each component's scope is just its `path`, exactly as
before.

## How each component promotes independently

Each component gets its own promote workflow and its own concurrency lane. Cascade
Expand Down
45 changes: 43 additions & 2 deletions docs/src/content/docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@ any inheritable field it overrides.
|-------|--------|------|----------|-------------|
| `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. |
| `extra_paths` | emitted (behavior) | list | No | Additional globs beyond `path` that both fire this component's orchestrate workflow and count toward its version bump. Use it when a component depends on a shared library or a root build file outside its own subtree. See [Shared paths](#shared-paths). |

### Inheritable overrides

Expand All @@ -824,8 +825,48 @@ 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.
`action_pins`, `telemetry`, `merge_queue`, and `shared_paths`. Setting any of them
under a component is a parse error, as is any unknown field.

### Shared paths

A change to a shared dependency, a common library, a proto package, or a root
build file, lives outside every component's own `path`. Without help it fires no
component and bumps no version, so a busy shared subtree ships nothing. Two fields
close that gap by widening a component's effective path set beyond its `path`:

- `extra_paths` on a component adds globs that only that component depends on.
- `shared_paths` at the top level adds globs that every component depends on. It
is sugar for repeating the same glob in every component's `extra_paths`, and
applies only when a `components:` block is present.

Each glob in a component's effective set (its `path`, its `extra_paths`, and the
top-level `shared_paths`) reaches all three places a path matters: the emitted
`push` paths filter that fires the workflow, the per-callback change detection that
decides which builds and deploys run, and the commit range that computes the
version bump. A breaking (`feat!:` or `fix!:`) commit under a shared path bumps the
major of exactly the components that declare it, and leaves the rest untouched.

```yaml
ci:
config:
environments: [dev, prod]
shared_paths:
- libs/common/** # every component depends on this
components:
api:
path: services/api
tag_prefix: api-
extra_paths:
- libs/proto/** # only api depends on the proto package
web:
path: services/web
tag_prefix: web-
```

Here a commit under `libs/common/` bumps both `api` and `web`; a commit under
`libs/proto/` bumps only `api`. When neither field is set the effective path set is
just each component's `path`, byte-identical to before the fields existed.

### Validation rules

Expand Down
75 changes: 75 additions & 0 deletions e2e/scenarios/56-component-shared-paths.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: "Per-Component Shared Path Threading"
description: |
Proves a component's extra_paths and the top-level shared_paths sugar both reach
the emitted orchestrate push-paths filter, so a commit touching a shared
dependency fires the workflows of the components that declare it.

The manifest declares two components. api adds libs/proto to its own extra_paths;
the top-level shared_paths adds libs/common to every component. web declares
neither, so it inherits only libs/common (the shared sugar) plus its own path.

Generation fans the orchestrate lane out to one orchestrate-<name>.yaml per
component. The proof is the emitted push-paths filter of each:
- orchestrate-api.yaml lists libs/common (shared), libs/proto (its extra_paths),
and services/api (its own path).
- orchestrate-web.yaml lists libs/common (shared) and services/web (its own
path), and NOT libs/proto (a path only api declares), so a non-consumer is not
triggered by another component's private dependency.

The version-commit-range and change-detection sinks of the same effective path
set are covered by the orchestrate unit tests (a breaking shared-path commit
bumps only the declaring component). This scenario pins the generated-output
sink.

config:
trunk_branch: main
environments: [dev, prod]
shared_paths:
- libs/common/**
builds:
- name: app
workflow: build.yaml
triggers: ["services/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["services/**"]
components:
api:
path: services/api
tag_prefix: api-
extra_paths:
- libs/proto/**
web:
path: services/web
tag_prefix: web-

steps:
- name: "Seed both component subtrees and assert the shared-path push filters"
action: commit
commit:
message: "feat: seed component sources"
files:
services/api/main.go: |
package main

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

func main() {}
expect:
workflow_files:
- path: ".github/workflows/orchestrate-api.yaml"
contains:
- " paths:\n"
- " - 'libs/common/**'\n"
- " - 'libs/proto/**'\n"
- " - 'services/api/**'\n"
- path: ".github/workflows/orchestrate-web.yaml"
contains:
- " paths:\n"
- " - 'libs/common/**'\n"
- " - 'services/web/**'\n"
not_contains:
- "libs/proto"
61 changes: 56 additions & 5 deletions internal/config/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"sort"
"strings"

"github.com/stablekernel/cascade/internal/taggrammar"
Expand Down Expand Up @@ -89,9 +90,16 @@ func (c *TrunkConfig) GetComponentTagPrefix(name string) (string, error) {
// per-component axis with no home on TrunkConfig, so it is carried here rather
// than folded into Config; downstream stages scope version and state work to it.
type ResolvedComponent struct {
Name string
Path string
Config *TrunkConfig
Name string
Path string
// ExtraPaths is the component's effective additional path set: its own
// extra_paths unioned with the manifest's top-level shared_paths, deduplicated
// and deterministically ordered. It is additive to Path. Version derivation and
// change detection scope to Path plus ExtraPaths so a shared-dependency change
// bumps and fires this component; it is empty when neither field is declared,
// keeping the single-component and no-shared-path shapes unchanged.
ExtraPaths []string
Config *TrunkConfig
}

// ResolveComponent returns the effective configuration for the named component:
Expand All @@ -117,7 +125,8 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error)
if err != nil {
return nil, err
}
eff.Components = nil // an effective per-component config has no nested components
eff.Components = nil // an effective per-component config has no nested components
eff.SharedPaths = nil // top-level sugar, expanded into per-component extra paths below

// Required per-component tag namespace.
eff.TagPrefix = comp.TagPrefix
Expand Down Expand Up @@ -218,7 +227,48 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error)
eff.Triggers = []string{strings.TrimRight(comp.Path, "/") + "/**"}
}

return &ResolvedComponent{Name: name, Path: comp.Path, Config: eff}, nil
// Effective additional paths: the component's own extra_paths unioned with the
// manifest's top-level shared_paths, deduplicated and sorted for a single stable
// downstream representation. These fire the workflow (folded into the push
// filter below) and, threaded by the orchestrator, count toward the version bump
// and change detection. When both are empty this is nil and nothing changes.
extraPaths := mergePaths(comp.ExtraPaths, c.SharedPaths)

// Fold the extra paths into the push-paths filter so a shared-dependency change
// fires this component's orchestrate workflow. GetAllTriggers reads eff.Triggers,
// so appending here reaches the emitted on.push.paths block. Deduplicated so an
// extra path that already equals a trigger is not repeated.
if len(extraPaths) > 0 {
eff.Triggers = mergePaths(eff.Triggers, extraPaths)
}

return &ResolvedComponent{Name: name, Path: comp.Path, ExtraPaths: extraPaths, Config: eff}, nil
}

// mergePaths returns the union of the given path lists with duplicates removed and
// a deterministic (sorted) order, or nil when the union is empty. It backs the
// shared_paths fan-out and the push-filter fold so every downstream sink sees one
// stable representation of a component's effective additional paths.
func mergePaths(lists ...[]string) []string {
seen := make(map[string]struct{})
var out []string
for _, list := range lists {
for _, p := range list {
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
sort.Strings(out)
return out
}

// TagGrammarSpec returns the tag grammar a component reads and emits its versions
Expand Down Expand Up @@ -260,4 +310,5 @@ var globalOnlyComponentFields = map[string]struct{}{
"telemetry": {},
"merge_queue": {},
"components": {},
"shared_paths": {},
}
Loading