Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d31efe8
feat(config): add optional tag_grammar block (schema_version 1)
joshua-temple Jul 7, 2026
7f75901
feat: honor the manifest tag_grammar in orchestrate and the version c…
joshua-temple Jul 7, 2026
5c74eef
feat(version): tolerate foreign prereleases and build metadata on read
joshua-temple Jul 7, 2026
fa95a25
docs(manifest): document the tag_grammar block
joshua-temple Jul 7, 2026
2703f1a
docs(manifest): note tag grammar versus version constraints
joshua-temple Jul 7, 2026
30f43b3
docs(cli): note next-version honors tag_grammar
joshua-temple Jul 7, 2026
d389449
docs(versioning): generalize tag-shape prose for configurable grammar
joshua-temple Jul 7, 2026
0c653a1
docs(release): qualify rc-shape claims as the default grammar
joshua-temple Jul 7, 2026
e8e9485
docs(readme): mention configurable tag grammar
joshua-temple Jul 7, 2026
09d5000
docs(contributing): require a single canonical tag-grammar source
joshua-temple Jul 7, 2026
822ef44
fix(promote): strip and enforce monotonicity using the configured tag…
joshua-temple Jul 7, 2026
b467db6
fix(generate): derive the release-tag strip pattern from tag_grammar
joshua-temple Jul 7, 2026
fcd1856
fix(hotfix): thread the configured tag grammar through allocation and…
joshua-temple Jul 7, 2026
d97f958
test(e2e): drive release and promote on a non-default tag grammar
joshua-temple Jul 7, 2026
2ae800f
fix(config): restrict tag_grammar values to shell- and regex-safe cha…
joshua-temple Jul 7, 2026
d604f43
fix(schema): declare the tag_grammar block in the manifest json schema
joshua-temple Jul 7, 2026
551dc85
fix(e2e): make the release-cleanup reaper honor the configured tag gr…
joshua-temple Jul 7, 2026
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
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ cascade owns the third-party action pins it emits into generated workflows, and
- Generated files are targets, never sources: a pin (or any other value) is read from the manifest and written into generated output, never read back out of a generated file. This keeps generation a pure, offline function of the manifest, which is what makes a regenerate reproducible and a diff meaningful.
- cascade's own self-heal companion is generated, not hand-written. `.github/workflows/pin-reconcile.yaml` is produced by the same reconcile generator that emits a downstream user's companion, in its own-repo variant, and is drift-locked byte-for-byte by a test so a hand-edit fails the suite. The own-repo variant differs from the user emission in exactly three ways: it installs the latest non-prerelease cascade release (never an rc or a draft, so cascade's own CI cannot self-install a prerelease), it scans both the workflow and composite-action trees for a moved pin, and it commits the regenerated workflows alongside the updated `action_pins.yaml`. Change the generator and regenerate the file; never edit the workflow by hand.

## Tag grammar

cascade owns one canonical shape for its release tags, and that ownership rests on a few rules that any code touching version tags must keep:

- `internal/taggrammar` is the single source of truth for the shape of a release tag: the prefix, the pre-release token, the separator, and the dry-run token. No other package hand-copies a tag regex or a format string; every tag sink (version parsing, the git tag predicate, the promote-boundary strip, generated workflow templates, hotfix segment allocation) derives its behavior from a resolved `taggrammar.Spec`, never a re-implementation of it.
- A manifest's `tag_grammar` block resolves to exactly one `taggrammar.Spec` per repository (`internal/config`), and that resolved spec is threaded through, not re-read piecemeal from manifest fields at each call site.
- Read-side tolerance (recognizing a foreign pre-release shape or build metadata left over from before `tag_grammar` was adopted) lives in the shared grammar package too, so every consumer stays consistent about what counts as a version tag.
- cascade's own self-release tooling (`nightly-release.yaml`, `release.yaml`, and the fleet) stays pinned to the default grammar (`taggrammar.Default()`) regardless of what a driven repository configures; it never resolves a manifest's `tag_grammar` for cascade's own tags.

## Documentation quality

A change that alters behavior, CLI surface, flags, config or manifest fields, generated output, or the release flow updates the affected docs in the same pull request: the docs site under `docs/src/content/docs/`, the root `README.md`, and any other affected Markdown file. The docs site follows these rules:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Opt-in companions (drift-check, PR-preview, pin-reconcile) are emitted only when
| Action pinning | `pin_mode: tag` (default) or `sha`, with an embedded action-pins manifest and an opt-in reconcile companion. |
| PR plan preview | An opt-in comment on each PR shows which builds and deploys would run. |
| Breaking-change gate | `feat!:` or `BREAKING CHANGE:` commits block the prerelease-to-release boundary unless overridden. |
| Tag grammar | Release tags follow `vX.Y.Z-rc.N` by default; the prefix, prerelease token, and separator are configurable via `tag_grammar`, and an omitted block reproduces today's shape exactly. |
| 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. |
Expand Down
32 changes: 32 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"type": "string",
"description": "Version tag prefix (default: \"v\")."
},
"tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" },
"release_token": {
"type": "string",
"description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})."
Expand Down Expand Up @@ -696,6 +697,37 @@
"comment": { "type": "boolean" }
}
},
"tagGrammarConfig": {
"type": "object",
"additionalProperties": false,
"description": "Optional, additive reshaping of the release tag grammar. Every field is optional; an omitted field inherits cascade's historical default, so an empty block leaves the default grammar (for example v1.2.3, v1.2.3-rc.1, v1.2.3-dryrun.1) unchanged. Component values are restricted to letters, digits, '.', '_', and '-' so every emitted tag stays a valid git ref and shell-safe token.",
"properties": {
"prefix": {
"type": "string",
"pattern": "^[A-Za-z0-9._-]*$",
"description": "Version tag prefix (default: \"v\"). When set, this wins over the manifest-level tag_prefix. May be empty for a prefix-less grammar."
},
"prerelease_token": {
"type": "string",
"pattern": "^[A-Za-z0-9._-]+$",
"description": "Token marking a pre-release tag (default: \"rc\"). Must be non-empty and must differ from dryrun_token so rehearsal tags stay distinguishable."
},
"prerelease_separator": {
"type": "string",
"pattern": "^[A-Za-z0-9._-]*$",
"description": "Separator between the base version and the pre-release token (default: \"-\"). May be empty."
},
"dryrun_token": {
"type": "string",
"pattern": "^[A-Za-z0-9._-]+$",
"description": "Token marking a dry-run rehearsal tag (default: \"dryrun\"). Must differ from prerelease_token."
},
"strict_prefix": {
"type": "boolean",
"description": "Require the configured prefix on parse rather than accepting a prefix-less version (default: false)."
}
}
},
"rollbackConfig": {
"type": "object",
"additionalProperties": false,
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/hotfix.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Re-dispatch targeting the same environment afterward to resume the chain from wh

## Version grammar

A hotfix allocates its own version segment so it sorts correctly relative to the rc sequence it interrupts. See [Hotfix version grammar](/cascade/reference/versioning/#hotfix-version-grammar) for the full derivation; the short form is `-rc.N.hotfix.M` for an unpublished (rc) base, or the next free patch for an already-published base.
A hotfix allocates its own version segment so it sorts correctly relative to the pre-release sequence it interrupts. See [Hotfix version grammar](/cascade/reference/versioning/#hotfix-version-grammar) for the full derivation; the short form is `-rc.N.hotfix.M` by default (configurable via [`tag_grammar`](/cascade/reference/manifest/#tag_grammar)) for an unpublished base, or the next free patch for an already-published base.

## What to watch

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/promote.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Promote also skips a deploy on its own when there is nothing to do: it compares

When the manifest has a `publish:` callback, crossing the prerelease-to-release boundary adds a publish step once per configured build. See [Publish](/cascade/reference/generated-workflows/#publish) for the exact dispatch payload.

For the release stage, version is the latest semver tag auto-incremented from conventional commits since that tag (major for a breaking change, minor for a feature, patch for a fix), or an explicit `version_override` input when you need to force a specific bump. The rc suffix is dropped at this boundary.
For the release stage, version is the latest semver tag auto-incremented from conventional commits since that tag (major for a breaking change, minor for a feature, patch for a fix), or an explicit `version_override` input when you need to force a specific bump. The prerelease suffix (`-rc.N` by default; configurable via [`tag_grammar`](/cascade/reference/manifest/#tag_grammar)) is dropped at this boundary.

## What to watch

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/internals/coverage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ only under real installation tokens on the fleet, never in the token-free harnes
| Default promotion (env to next env) | `04`, `promote/cascade-deploy-enabled` | `promote-staging` (2env, 3env, primary) | `internal/promote` | One promotion step copies source state into the target on a real release object |
| Cascade-mode promotion (atomic multi-step) | `04-cascade-promotion` | `lifecycle` dev to prod (4env) | `internal/promote` | The full ladder advances through intermediates and publishes at the top |
| Standalone release lane (draft, prerelease, publish) | `05-publish-callback`, `37`, `38` | dispatch prerelease then release (single-env); `release-only` | `internal/release` | A real release transitions draft to prerelease to published with RC reaping |
| Non-default tag grammar (custom prefix, token, separator) | `48-tag-grammar-custom-release-promote` | | `internal/taggrammar`, `internal/promote`, `internal/orchestrate` | A manifest `tag_grammar` reshapes the emitted candidate tag and recorded state, and promotion strips the custom pre-release token to publish |
| Hotfix clean apply | `hotfix/hotfix-clean-apply`, `hotfix-multi-commit-clean`, `hotfix-multi-env-clean`, `hotfix-rejoin` | hotfix plan, apply, PR merge, finalize (3env) | `internal/hotfix` | A pinned-env fix lands, diverges state, and rejoins on real branches and PRs |
| Hotfix cherry-pick conflict and halt | `hotfix/hotfix-conflict-resolution`, `hotfix-multi-env-conflict-halt` | `probe_hotfix_conflict` (4env) | `internal/hotfix` | A guaranteed conflict raises the conflict label and halts the downstream lane |
| Rollback to prior version or SHA | `rollback/*` (8 scenarios) | `probe_rollback` (4env), `rollback-check` (2env) | `internal/rollback` | An env rewinds, is marked diverged, and the ring snapshot advances |
Expand Down
21 changes: 21 additions & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,27 @@ Bump rules:
- Fix (`fix`) triggers a patch bump.
- Pre-release environments append an RC suffix (e.g., `v1.3.0-rc.0`).

`next-version` resolves [`tag_grammar`](/cascade/reference/manifest/#tag_grammar) from the
manifest and formats the calculated version under that grammar, so its output matches what
`orchestrate` cuts and what the generated release workflow publishes. With no `tag_grammar`
block the output keeps the historical `rc.N` shape shown above. A manifest with a custom
`prerelease_token` and `prerelease_separator`:

```yaml
ci:
config:
tag_grammar:
prerelease_token: pre
prerelease_separator: ""
```

changes the emitted shape:

```bash
cascade next-version --environment prod --base-sha abc123 --head-sha def456
# v1.3.0-pre0
```

### generate-changelog

Generate a markdown changelog from conventional commits.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/generated-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Orchestrate takes no manual inputs; it runs automatically on push. Its outputs:
| `release_url` | URL to the GitHub release. |
| `execution_plan` | JSON execution plan with dependency-ordered waves. |

The setup job reads the manifest's recorded SHA, diffs it against the current head, matches changed files against each callback's triggers, and builds an execution plan that respects `depends_on`. Version is computed from conventional commits since the last release: `feat!:`/`BREAKING CHANGE:` bumps major, `feat:` bumps minor, `fix:`/`perf:` bumps patch. The first environment always gets an rc suffix (`v1.2.0-rc.0`); each further orchestrate run increments the rc counter.
The setup job reads the manifest's recorded SHA, diffs it against the current head, matches changed files against each callback's triggers, and builds an execution plan that respects `depends_on`. Version is computed from conventional commits since the last release: `feat!:`/`BREAKING CHANGE:` bumps major, `feat:` bumps minor, `fix:`/`perf:` bumps patch. The first environment always gets a pre-release suffix (`v1.2.0-rc.0` by default; configurable via [`tag_grammar`](/cascade/reference/manifest/#tag_grammar)); each further orchestrate run increments the pre-release counter.

## Promote workflow anatomy

Expand Down
56 changes: 56 additions & 0 deletions docs/src/content/docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,57 @@ The `environments` list is fully configurable. cascade attaches no meaning to sp

Workflow-level trigger types beyond `push` are set under [`extra_triggers`](#extra_triggers).

### tag_grammar

Optional, additive block that reshapes the release tag grammar. A manifest that omits it
produces cascade's historical grammar exactly: `vX.Y.Z` releases, `-rc.N` pre-releases, and
`.hotfix.M` hotfixes, so existing repositories are unaffected.

```yaml
ci:
config:
tag_grammar:
prefix: v
prerelease_token: rc
prerelease_separator: "."
dryrun_token: dryrun
strict_prefix: false
```

| Field | Status | Type | Default | Description |
|-------|--------|------|---------|-------------|
| `prefix` | emitted | string | `v` | Literal prefix cascade puts on every new tag. |
| `prerelease_token` | emitted | string | `rc` | Token that marks a release-candidate tag. |
| `prerelease_separator` | emitted | string | `.` | Separator between the token and its number. `.` yields `rc.4`; an empty string yields `rc4`. |
| `dryrun_token` | emitted | string | `dryrun` | Token that marks a rehearsal tag. |
| `strict_prefix` | emitted | bool | false | When false, reads accept any alphabetic prefix so historical and foreign-cased tags still parse. When true, reads require the exact configured prefix. |

**Relationship to `tag_prefix`.** `tag_prefix` still sets the prefix on its own when
`tag_grammar` is absent. When both `tag_prefix` and `tag_grammar.prefix` are set,
`tag_grammar.prefix` wins, and `cascade parse-config` emits a non-fatal warning naming both
keys so the redundancy is visible. Resolution is well defined either way; the warning is
advisory only.

**Reading pre-existing tags.** On read, cascade tolerates a pre-existing foreign pre-release
shape (for example `beta.1` or `rc1`) and build metadata (for example `+build.5`) so a
repository whose history predates `tag_grammar` stays visible to version discovery. cascade
never emits those shapes itself, and a recognized foreign pre-release always sorts below its
release.

**cascade's own releases.** cascade's own self-release workflows stay pinned to the default
grammar regardless of what a driven repository configures. `tag_grammar` reshapes the tags a
driven repository's pipeline cuts, not cascade's own release process.

See [Versioning and schema](/cascade/reference/versioning/) for the hotfix version grammar
and the full reserved-shapes catalog.

:::note[Tag grammar is not version selection]
`tag_grammar` answers how a tag is shaped, not which versions are selected. Constraints or
ranges over versions, such as a selector accepting only `>=v1.2`, are a different concern
and are out of scope here. Should that capability arrive, it lands as its own additive
optional block rather than being folded into `tag_grammar`.
:::

## CLI pinning

These fields pin the cascade CLI and third-party actions the generated workflows install.
Expand Down Expand Up @@ -812,6 +863,11 @@ The implicit `release` slot tracks the most recently published (non-draft) GitHu
- A repository cannot set both `external` (primary) and `notify` (satellite).
- A per-callback `permissions` block is the complete permission set for that caller job and replaces the workflow default rather than merging.
- `cli_version_sha` takes effect only under `pin_mode: sha`.
- `tag_grammar.prerelease_token` must not be empty. `tag_grammar.prefix`,
`prerelease_token`, `prerelease_separator`, and `dryrun_token` must not contain
whitespace, control characters, or a git-ref-unsafe character (any of `/`, `~`, `^`, `:`,
`?`, `*`, `[`, or a backslash). The resolved `dryrun_token` must differ from the resolved
`prerelease_token`.

## What to read next

Expand Down
19 changes: 15 additions & 4 deletions docs/src/content/docs/reference/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,11 @@ Older tags outside the current release line do not receive backported fixes. See

## Hotfix version grammar

A hotfix applies one or more trunk commits onto an environment pinned to an older trunk base (see the [hotfix guide](/cascade/guides/hotfix/)). The version cascade allocates for a hotfix depends on whether the environment's current version is still in flight (an rc) or already published.
A hotfix applies one or more trunk commits onto an environment pinned to an older trunk base (see the [hotfix guide](/cascade/guides/hotfix/)). The version cascade allocates for a hotfix depends on whether the environment's current version is still in flight (a pre-release) or already published.

### rc-based (unpublished) base

When the environment holds an rc version, the hotfix appends a nested `hotfix.M` segment:
When the environment holds a pre-release version, the hotfix appends a nested `hotfix.M` segment. With the default grammar (prerelease token `rc`, separator `.`):

```
v1.4.0-rc.2 -> v1.4.0-rc.2.hotfix.1 (first hotfix)
Expand All @@ -157,7 +157,7 @@ The dotted form is deliberate. Under semver precedence the pre-release field lis
v1.4.0-rc.2 < v1.4.0-rc.2.hotfix.1 < v1.4.0-rc.2.hotfix.2 < v1.4.0-rc.3
```

A hotfix version therefore slots cleanly between its base rc and the next rc, and it never collides with the orchestrator's rc sequence. The rc-shaped tag and draft cleanup logic matches the plain `<prefix>X.Y.Z-rc.N` shape for the configured `tag_prefix` (the default `v`, a custom prefix such as `rel-`, or no prefix), so it is inert on hotfix tags; hotfix tags and drafts are cleaned up explicitly when the divergence ends.
A hotfix version therefore slots cleanly between its base pre-release and the next one, and it never collides with the orchestrator's pre-release sequence. The general shape is `<prefix>X.Y.Z-<token><separator>N.hotfix.M`, where the prefix, token, and separator come from the resolved [`tag_grammar`](/cascade/reference/manifest/#tag_grammar) (`v`, `rc`, and `.` unless configured otherwise); the nested `hotfix.M` segment itself is fixed and not reshaped by `tag_grammar`. The pre-release-shaped tag and draft cleanup logic matches this same resolved shape, so it is inert on hotfix tags; hotfix tags and drafts are cleaned up explicitly when the divergence ends.

### Published (no rc) base

Expand All @@ -168,7 +168,18 @@ v1.3.0 -> v1.3.1 (first hotfix)
v1.3.1 -> v1.3.2 (next free patch)
```

cascade allocates the next free patch by reconciling against existing tags, so the hotfix does not collide with a patch the normal release flow may also mint. There is no `vX.Y.Z-hotfix.M` form; the nested `hotfix.M` segment applies only to rc-based, still-in-flight versions.
cascade allocates the next free patch by reconciling against existing tags, so the hotfix does not collide with a patch the normal release flow may also mint. There is no `vX.Y.Z-hotfix.M` form; the nested `hotfix.M` segment applies only to still-in-flight, pre-release versions.

## Tag grammar

The `-rc.N` shape used throughout this page is cascade's default pre-release grammar, not a fixed rule. An optional [`tag_grammar`](/cascade/reference/manifest/#tag_grammar) manifest block reshapes the prefix, the pre-release token, and the separator between the token and its number, so the general tag shape is `<prefix>X.Y.Z-<token><separator>N[.hotfix.M]`. A manifest that omits `tag_grammar` reproduces the historical grammar shown above byte-identically.

Two rules bound how far this configurability goes:

- **Read tolerance.** On read, cascade also recognizes a foreign pre-release shape (for example `beta.1` or `rc1`) and build metadata (for example `+build.5`) left over from before `tag_grammar` was adopted, so an existing repository's tag history stays visible to version discovery. cascade never emits those shapes itself, and a recognized foreign pre-release always sorts below its release.
- **Clean release boundary only.** Changing `tag_grammar` is supported at a clean release boundary, when no version is currently in flight. There is no mid-flight migration window that mixes two grammars across the same in-progress release.

See the [manifest reference](/cascade/reference/manifest/#tag_grammar) for the full field list and defaults.

## Version bump reference

Expand Down
Loading