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
16 changes: 16 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"validate_check": { "$ref": "#/definitions/validateCheckConfig" },
"merge_queue": { "$ref": "#/definitions/mergeQueueConfig" },
"drift_check": { "$ref": "#/definitions/driftCheckConfig" },
"rollback": { "$ref": "#/definitions/rollbackConfig" },
"deployments": { "$ref": "#/definitions/deploymentsConfig" },
"pin_mode": {
"type": "string",
Expand Down Expand Up @@ -668,6 +669,21 @@
"comment": { "type": "boolean" }
}
},
"rollbackConfig": {
"type": "object",
"additionalProperties": false,
"description": "Opt-in configuration for the generated rollback workflow. Absent by default. When repository_dispatch is set, an external system can fire the same N-1 rollback the manual path performs by calling the dispatches API, carrying the parameters (environment, target, deployable, dry_run) in client_payload.",
"properties": {
"repository_dispatch": {
"type": "object",
"additionalProperties": false,
"description": "Adds a repository_dispatch trigger to the rollback workflow so an external signal can drive the rollback.",
"properties": {
"types": { "type": "array", "items": { "type": "string" } }
}
}
}
},
"deploymentsConfig": {
"type": "object",
"additionalProperties": false,
Expand Down
44 changes: 44 additions & 0 deletions docs/src/content/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,50 @@ For the trunk branch, `cascade branch-protection` emits the full JSON body to PU

> The `rollback_sha` output in the generated workflow is a disclosed placeholder today: the deploy and rollback jobs mirror the promote workflow's shape, and the rollback path activates once a CLI output supplies the prior SHA.

## Rollback

cascade generates a standalone `cascade-rollback.yaml` workflow whenever the manifest declares at least one environment. It re-deploys a prior version or SHA to a target environment, defaulting to the previous version (N-1). A read-only preflight resolves the target, the deploy stage re-runs the configured deploy callbacks keyed on the resolved SHA, and finalize writes the rolled-back state back to trunk.

By default the workflow is triggered by manual dispatch only (`workflow_dispatch`).

### External-signal trigger

To let an external system (an alerting or incident pipeline) drive the same rollback automatically, opt into a `repository_dispatch` trigger:

```yaml
rollback:
repository_dispatch:
types: [rollback-requested]
```

When set, the generated rollback workflow gains a `repository_dispatch` trigger alongside the unchanged `workflow_dispatch`, and every rollback parameter read coalesces the manual input with the dispatch payload:

```yaml
ENVIRONMENT: ${{ github.event.inputs.environment || github.event.client_payload.environment }}
```

so both trigger paths resolve the same target. When the block is absent, the rollback workflow is byte-for-byte unchanged (manual dispatch only). At least one event type is required, and each type may contain only letters, digits, dots, hyphens, and underscores.

`repository_dispatch` carries no `inputs`, so an external caller supplies the rollback parameters in `client_payload`. The keys map name-for-name onto the manual `workflow_dispatch` inputs:

| `client_payload` key | Rollback parameter | Meaning |
| --- | --- | --- |
| `environment` | environment | Environment to roll back (required) |
| `target` | target | Prior version or SHA; omit for the previous version (N-1) |
| `deployable` | deployable | Limit the rollback to one deployable; omit for the whole environment |
| `dry_run` | dry_run | When `"true"`, resolve and print without deploying |

An external system fires the rollback with a single dispatches API call (substitute your own org and repo):

```bash
gh api repos/my-org/my-repo/dispatches \
-f event_type=rollback-requested \
-F 'client_payload[environment]=prod' \
-F 'client_payload[target]=v1.4.2'
```

The event type must match one of the configured `types`. Because the trigger fires the same N-1 rollback the manual path performs, the dispatching system needs no rollback logic of its own.

## Workflow Permissions

Generated workflows include the necessary permissions:
Expand Down
5 changes: 5 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ type Config struct {
// scenario can enable the finalize-seam Deployments API steps. A generic map
// keeps the harness decoupled from the generator's DeploymentsConfig shape.
Deployments map[string]any `yaml:"deployments,omitempty"`
// Rollback carries the opt-in rollback block (repository_dispatch) through to
// the generated manifest untouched, so a scenario can enable the external
// repository_dispatch trigger on the rollback workflow (#181). A generic map
// keeps the harness decoupled from the generator's RollbackConfig shape.
Rollback map[string]any `yaml:"rollback,omitempty"`
Notify map[string]any `yaml:"notify,omitempty"`
External []map[string]any `yaml:"external,omitempty"`
// Telemetry carries the reserved vendor-neutral telemetry block (enabled,
Expand Down
71 changes: 71 additions & 0 deletions e2e/scenarios/32-rollback-repository-dispatch.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: "Rollback Repository Dispatch"
description: |
Exercises the opt-in rollback.repository_dispatch trigger (#181). With the
toggle enabled, the generated cascade-rollback workflow gains a
repository_dispatch trigger (alongside the unchanged workflow_dispatch), and
every rollback parameter read coalesces the manual input with the dispatch
client_payload, so an external alerting or incident pipeline can fire the same
N-1 rollback the manual path performs by calling the GitHub dispatches API.

The client_payload key mapping is name-for-name with the workflow_dispatch
inputs: environment, target, deployable, dry_run.

This is generator-output verification. The harness fires workflow_dispatch via
act and cannot synthesize a repository_dispatch event with a client_payload, so
the dispatch path is asserted structurally (the emitted trigger and the
coalesced reads) rather than executed. The workflow_dispatch path remains
fully exercised by the rollback runtime scenarios. After asserting the emitted
shape, regenerate and prove the output round-trips through verify with no drift.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["src/**"]
rollback:
repository_dispatch:
types: [rollback-requested]

steps:
- name: "Seed source; assert repository_dispatch trigger and coalesced reads"
action: commit
commit:
message: "feat: add app"
files:
src/app.go: |
package main

func main() {}
expect:
workflow_files:
- path: ".github/workflows/cascade-rollback.yaml"
contains:
# workflow_dispatch path is preserved.
- " workflow_dispatch:"
# opt-in repository_dispatch trigger with the configured event type.
- " repository_dispatch:"
- " types:"
- " - rollback-requested"
# preflight reads coalesce manual inputs with the dispatch payload.
- "ENVIRONMENT: ${{ github.event.inputs.environment || github.event.client_payload.environment }}"
- "TARGET: ${{ github.event.inputs.target || github.event.client_payload.target }}"
- "DEPLOYABLE: ${{ github.event.inputs.deployable || github.event.client_payload.deployable }}"
# deploy guard and finalize gate coalesce dry_run and deployable.
- "github.event.inputs.dry_run || github.event.client_payload.dry_run"
- "github.event.inputs.deployable || github.event.client_payload.deployable"
not_contains:
# the bare, un-coalesced reads must be gone once the toggle is on.
- "ENVIRONMENT: ${{ github.event.inputs.environment }}"
- "TARGET: ${{ github.event.inputs.target }}"

- name: "Regenerate and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0
17 changes: 17 additions & 0 deletions internal/config/schema_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,23 @@ type DeploymentsConfig struct {
KeepPriorActive bool `yaml:"keep_prior_active,omitempty" json:"keep_prior_active,omitempty"`
}

// RollbackConfig is the opt-in configuration block for the generated rollback
// workflow (#181). It is absent by default: when nil, the rollback workflow is
// byte-identical to the workflow_dispatch-only baseline.
//
// RepositoryDispatch, when set, adds a repository_dispatch trigger so an external
// system (an alerting or incident pipeline) can fire the same N-1 rollback the
// manual path performs by calling the GitHub dispatches API. The dispatch carries
// the rollback parameters in client_payload, and the preflight job coalesces the
// reads so both the manual (github.event.inputs.*) and external
// (github.event.client_payload.*) trigger paths resolve the same target.
type RollbackConfig struct {
// RepositoryDispatch opts the rollback workflow into a repository_dispatch
// trigger. Reuses the shared RepositoryDispatchTrigger shape so the event
// types are configured the same way as extra_triggers.repository_dispatch.
RepositoryDispatch *RepositoryDispatchTrigger `yaml:"repository_dispatch,omitempty" json:"repository_dispatch,omitempty"`
}

// Pin mode constants.
const (
PinModeTag = "tag"
Expand Down
3 changes: 3 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ type TrunkConfig struct {
ValidateCheck *ValidateCheckConfig `yaml:"validate_check,omitempty" json:"validate_check,omitempty"`
MergeQueue *MergeQueueConfig `yaml:"merge_queue,omitempty" json:"merge_queue,omitempty"`
DriftCheck *DriftCheckConfig `yaml:"drift_check,omitempty" json:"drift_check,omitempty"` // Opt-in workflow drift-check PR lane (#229)
// Rollback configures the opt-in rollback workflow. Absent by default; when
// set with repository_dispatch, an external signal can fire the rollback (#181).
Rollback *RollbackConfig `yaml:"rollback,omitempty" json:"rollback,omitempty"`
// Deployments configures opt-in GitHub Deployments API integration.
Deployments *DeploymentsConfig `yaml:"deployments,omitempty" json:"deployments,omitempty"`
PinMode string `yaml:"pin_mode,omitempty" json:"pin_mode,omitempty"` // tag | sha (default tag)
Expand Down
77 changes: 77 additions & 0 deletions internal/config/validate_rollback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package config

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

// rollbackBaseConfig is a minimal valid multi-env manifest used to isolate the
// rollback.repository_dispatch validation rules.
func rollbackBaseConfig() *TrunkConfig {
return &TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
}
}

func TestValidate_RollbackRepositoryDispatch_Valid(t *testing.T) {
cfg := rollbackBaseConfig()
cfg.Rollback = &RollbackConfig{
RepositoryDispatch: &RepositoryDispatchTrigger{
Types: []string{"rollback-requested"},
},
}
errs := Validate(cfg)
assert.Empty(t, errs, "a valid rollback.repository_dispatch must pass validation")
}

func TestValidate_RollbackRepositoryDispatch_NilIsValid(t *testing.T) {
cfg := rollbackBaseConfig()
cfg.Rollback = nil
errs := Validate(cfg)
assert.Empty(t, errs)
}

func TestValidate_RollbackRepositoryDispatch_EmptyTypesRejected(t *testing.T) {
cfg := rollbackBaseConfig()
cfg.Rollback = &RollbackConfig{
RepositoryDispatch: &RepositoryDispatchTrigger{Types: nil},
}
errs := Validate(cfg)
assert.NotEmpty(t, errs, "an empty repository_dispatch types list must be rejected")
assert.Contains(t, strings.Join(errs, "\n"), "rollback.repository_dispatch")
}

func TestValidate_RollbackRepositoryDispatch_BlankTypeRejected(t *testing.T) {
cfg := rollbackBaseConfig()
cfg.Rollback = &RollbackConfig{
RepositoryDispatch: &RepositoryDispatchTrigger{Types: []string{" "}},
}
errs := Validate(cfg)
assert.NotEmpty(t, errs, "a blank event type must be rejected")
}

func TestValidate_RollbackRepositoryDispatch_UnsafeTypeRejected(t *testing.T) {
cfg := rollbackBaseConfig()
cfg.Rollback = &RollbackConfig{
RepositoryDispatch: &RepositoryDispatchTrigger{Types: []string{"bad type/name"}},
}
errs := Validate(cfg)
assert.NotEmpty(t, errs, "an unsafe event type must be rejected")
}

// TestValidate_RollbackRepositoryDispatch_SchemaVersionUnchanged proves a
// manifest using rollback.repository_dispatch validates at CurrentSchemaVersion
// with no schema_version bump required.
func TestValidate_RollbackRepositoryDispatch_SchemaVersionUnchanged(t *testing.T) {
cfg := rollbackBaseConfig()
cfg.SchemaVersion = CurrentSchemaVersion
cfg.Rollback = &RollbackConfig{
RepositoryDispatch: &RepositoryDispatchTrigger{Types: []string{"rollback-requested"}},
}
errs := Validate(cfg)
assert.Empty(t, errs)
assert.Equal(t, CurrentSchemaVersion, cfg.GetSchemaVersion())
}
44 changes: 44 additions & 0 deletions internal/config/validate_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,54 @@ func validateConfigLevel(cfg *TrunkConfig) []string {
errs = append(errs, validateTelemetry(cfg.Telemetry)...)
errs = append(errs, validateEnvironmentConfig(cfg)...)
errs = append(errs, validateTokenSources(cfg)...)
errs = append(errs, validateRollback(cfg.Rollback)...)

return errs
}

// repositoryDispatchTypeRe matches a repository_dispatch event type that is safe
// to emit verbatim under the on.repository_dispatch.types list. GitHub accepts
// arbitrary client-chosen event-type strings, but cascade renders them into YAML
// without quoting, so the accepted set is constrained to characters that need no
// escaping and cannot break the surrounding document.
var repositoryDispatchTypeRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)

// validateRepositoryDispatchTypes checks a repository_dispatch trigger's event
// types under the given prefix. At least one type is required (an empty list
// would make the trigger fire on every event type, which is never the intent for
// an opt-in rollback signal), and each type must be non-blank and safe to emit.
func validateRepositoryDispatchTypes(prefix string, rd *RepositoryDispatchTrigger) []string {
if rd == nil {
return nil
}
var errs []string
if len(rd.Types) == 0 {
errs = append(errs, fmt.Sprintf("%s.types must list at least one event type", prefix))
return errs
}
for _, t := range rd.Types {
if strings.TrimSpace(t) == "" {
errs = append(errs, fmt.Sprintf("%s.types must not contain a blank event type", prefix))
continue
}
if !repositoryDispatchTypeRe.MatchString(t) {
errs = append(errs, fmt.Sprintf(
"%s.types entry %q must contain only letters, digits, dots, hyphens, and underscores", prefix, t))
}
}
return errs
}

// validateRollback checks the opt-in rollback configuration. A nil block is the
// default and passes. When repository_dispatch is set, its event types are
// validated the same way the shared repository_dispatch trigger is.
func validateRollback(rb *RollbackConfig) []string {
if rb == nil {
return nil
}
return validateRepositoryDispatchTypes("rollback.repository_dispatch", rb.RepositoryDispatch)
}

// validateTokenSources checks the optional release_token_app / state_token_app
// GitHub App identities. Each is lenient when absent. When present, BOTH app_id
// and private_key must be set (a half-configured App is rejected), and each must
Expand Down
Loading
Loading