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
8 changes: 4 additions & 4 deletions docs/src/content/docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Each field carries an emission status:
|--------|---------|
| **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. |
| **reserved** | Parses, but has no generator consumption today, so `cascade lint` rejects any manifest that sets it. |

## File shape and schema support

Expand Down Expand Up @@ -944,16 +944,16 @@ These fields parse and pass schema validation but **never appear in generated YA
|-------|-------|-----------------------|
| `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. |
| `timeout_minutes` (per-callback) | `builds[]`, `deploys[]`, `publish` | The timeout belongs inside the called workflow, not the caller job; it is never emitted, so `cascade lint` rejects it. 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.
Reserved fields parse but have zero generator consumption today. They reserve a stable shape so a future capability can land without a schema break. Because a manifest that sets one is silently inert, `cascade lint` treats reserved-field use as an error: remove the field (or, in the demo, keep it commented) until the capability is wired.

| 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). |
| `rollout.type` / `rollout.canary` / `rollout.blue_green` | `deploys[]` | Reserved rollout sub-blocks (`rollout.fail_fast` and `rollout.max_parallel` are emitted and stay valid; the rest are inert and rejected). |
| `release.version_overrides` | `release` | Reserved pointer to version-intent override files. |
| `deploy_target` | `deploys[]` | Reserved shape for the GitOps mirror pattern. |

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

## 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`.
These fields parse and pass structural validation today but carry no generator, state, or runtime behavior. Because a manifest that declares one is silently inert, `cascade lint` rejects reserved-field use as an error rather than accepting a no-op manifest: the shape is frozen so a future capability can land without a schema break, not so it can be set today. Attaching behavior to any of them later is additive and does not bump `schema_version`.

### Progressive rollout: canary and blue/green

Expand Down Expand Up @@ -106,7 +106,7 @@ Only the addressing pointer is frozen in v1. The override-file format and the fo

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 `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.
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 structurally validates, but because it has no effect on generated output today, `cascade lint` rejects it; keep `rollout:` to `fail_fast` and `max_parallel` until the type-specific behavior is wired.

## Per-component versioning

Expand Down
50 changes: 0 additions & 50 deletions e2e/scenarios/23-canary-bluegreen-reserved.yaml

This file was deleted.

45 changes: 0 additions & 45 deletions e2e/scenarios/24-gitops-deploy-target-reserved.yaml

This file was deleted.

45 changes: 0 additions & 45 deletions e2e/scenarios/25-telemetry-sink-reserved.yaml

This file was deleted.

42 changes: 0 additions & 42 deletions e2e/scenarios/26-version-overrides-reserved.yaml

This file was deleted.

75 changes: 75 additions & 0 deletions e2e/scenarios/59-lint-reserved-rejection.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: "Lint Reserved Rejection"
description: |
Verifies that cascade lint rejects a manifest that populates a reserved
field (one that parses but is not wired to generation), and that the
merge_queue lane (which runs cascade lint --json as its validity gate) reds
when a candidate carries such a field.

The positive step runs the merge_group lane against a valid manifest and
asserts the merge-queue-validate job succeeds. The negative step overwrites
the manifest with one declaring the reserved telemetry block and asserts the
same lane reds, proving reserved-field usage blocks a merge that a lenient
parse would have silently accepted as an inert manifest.

config:
trunk_branch: main
environments: [dev]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys: []
merge_queue:
enabled: true

steps:
- name: "Initial commit; assert the merge-queue lane runs cascade lint --json"
action: commit
commit:
message: "feat: add app"
files:
src/app.go: |
package main
func main() {}
expect:
workflow_files:
- path: ".github/workflows/cascade-merge-queue.yaml"
contains:
- "merge-queue-validate:"
- "cascade lint --json"

- name: "POSITIVE: valid candidate passes the merge_group lane"
action: run_workflow
run_workflow:
workflow_path: ".github/workflows/cascade-merge-queue.yaml"
event: merge_group
expect:
jobs:
merge-queue-validate: success

- name: "Overwrite the manifest with a reserved telemetry block"
action: commit
commit:
message: "test: stage a manifest that uses a reserved field"
files:
.github/manifest.yaml: |
ci:
config:
trunk_branch: main
environments: [dev]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
merge_queue:
enabled: true
telemetry:
enabled: true
adapter: none

- name: "NEGATIVE: reserved-field candidate is blocked by the merge_group lane"
action: run_workflow
expect_failure: true
run_workflow:
workflow_path: ".github/workflows/cascade-merge-queue.yaml"
event: merge_group
7 changes: 4 additions & 3 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,6 @@ func Validate(cfg *TrunkConfig) []string {
// rejects on a jobs.<id>.uses call. matrix: is builds-only.
isReusable := b.Workflow != ""
errors = append(errors, validateJobControlFields(fmt.Sprintf("builds[%d]", i), isReusable, b.RunsOn, b.Concurrency)...)
errors = append(errors, validateCallbackTimeout(fmt.Sprintf("builds[%d]", i), isReusable, b.TimeoutMinutes)...)
errors = append(errors, validatePermissions(fmt.Sprintf("builds[%d]", i), b.Permissions)...)
errors = append(errors, validateSecrets(fmt.Sprintf("builds[%d]", i), b.Secrets)...)

Expand Down Expand Up @@ -267,7 +266,6 @@ func Validate(cfg *TrunkConfig) []string {
// rejects on a jobs.<id>.uses call. rollout: is deploys-only.
isReusable := d.Workflow != ""
errors = append(errors, validateJobControlFields(fmt.Sprintf("deploys[%d]", i), isReusable, d.RunsOn, d.Concurrency)...)
errors = append(errors, validateCallbackTimeout(fmt.Sprintf("deploys[%d]", i), isReusable, d.TimeoutMinutes)...)
errors = append(errors, validatePermissions(fmt.Sprintf("deploys[%d]", i), d.Permissions)...)
errors = append(errors, validateSecrets(fmt.Sprintf("deploys[%d]", i), d.Secrets)...)
errors = append(errors, validateRollout(fmt.Sprintf("deploys[%d]", i), d.Rollout, cfg.Environments)...)
Expand Down Expand Up @@ -318,7 +316,6 @@ func Validate(cfg *TrunkConfig) []string {
errors = append(errors, validateLocalCallbackWorkflowPath("validate", v.Workflow)...)
isReusable := v.Workflow != ""
errors = append(errors, validateJobControlFields("validate", isReusable, v.RunsOn, v.Concurrency)...)
errors = append(errors, validateCallbackTimeout("validate", isReusable, v.TimeoutMinutes)...)
errors = append(errors, validatePermissions("validate", v.Permissions)...)
errors = append(errors, validateSecrets("validate", v.Secrets)...)
}
Expand All @@ -327,6 +324,10 @@ func Validate(cfg *TrunkConfig) []string {
// mirroring the per-component unknown-field rejection one level up.
errors = append(errors, validateUnknownTopLevel(cfg)...)

// Reserved-schema usage is a hard error (front-2 strictness): a field that
// parses but is not wired to generation must not be silently accepted.
errors = append(errors, validateReservedFields(cfg)...)

// Config-level structural validation for v1 reserved fields.
errors = append(errors, validateConfigLevel(cfg)...)
errors = append(errors, validateComponents(cfg)...)
Expand Down
Loading