diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 4c6693d..8d34cb7 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -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", @@ -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, diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index fdfcb5e..973785c 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -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: diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index d82de7a..8d81f45 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -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, diff --git a/e2e/scenarios/32-rollback-repository-dispatch.yaml b/e2e/scenarios/32-rollback-repository-dispatch.yaml new file mode 100644 index 0000000..62d9552 --- /dev/null +++ b/e2e/scenarios/32-rollback-repository-dispatch.yaml @@ -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 diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 679ec96..472f5de 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -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" diff --git a/internal/config/types.go b/internal/config/types.go index bcd0e61..abfb9bf 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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) diff --git a/internal/config/validate_rollback_test.go b/internal/config/validate_rollback_test.go new file mode 100644 index 0000000..a36e0c5 --- /dev/null +++ b/internal/config/validate_rollback_test.go @@ -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()) +} diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 69923d7..b428a79 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -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 diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 63f5b20..8f2784a 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -39,6 +39,29 @@ func (g *RollbackGenerator) Enabled() bool { return g.config != nil && len(g.config.Environments) >= 1 } +// dispatchTrigger returns the configured opt-in repository_dispatch trigger, or +// nil when the rollback workflow is in its workflow_dispatch-only baseline. +func (g *RollbackGenerator) dispatchTrigger() *config.RepositoryDispatchTrigger { + if g.config == nil || g.config.Rollback == nil { + return nil + } + return g.config.Rollback.RepositoryDispatch +} + +// paramRead returns the GitHub Actions expression body that reads a single +// rollback parameter. In the baseline it reads github.event.inputs.; when +// the repository_dispatch trigger is enabled it coalesces to +// github.event.client_payload. so the same workflow resolves the parameter +// whether it was fired by the manual (workflow_dispatch) or external +// (repository_dispatch) path. The two paths share one parameter name: the +// client_payload key matches the workflow_dispatch input name exactly. +func (g *RollbackGenerator) paramRead(name string) string { + if g.dispatchTrigger() != nil { + return fmt.Sprintf("github.event.inputs.%s || github.event.client_payload.%s", name, name) + } + return fmt.Sprintf("github.event.inputs.%s", name) +} + // getCLIRef mirrors the ref-resolution used by the other generators so the // emitted setup-cli ref tracks config.cli_version. "beta" is the explicit opt-in // escape hatch to the "master" branch; everything else resolves through @@ -136,6 +159,14 @@ func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) { sb.WriteString(" required: false\n") sb.WriteString(" type: boolean\n") sb.WriteString(" default: false\n") + + // Opt-in repository_dispatch (#181): an external system (an alerting or + // incident pipeline) fires the same N-1 rollback the manual path performs by + // calling the dispatches API. repository_dispatch carries no inputs; the + // rollback parameters travel in client_payload (keys: environment, target, + // deployable, dry_run), which the jobs below coalesce with the manual inputs. + g.writeRepositoryDispatchTrigger(sb) + sb.WriteString("\n") // Base: contents:write to commit the rolled-back state; actions:write for @@ -150,6 +181,25 @@ func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) { writeTopLevelPermissions(sb, base) } +// writeRepositoryDispatchTrigger emits the opt-in repository_dispatch entry +// under on: when the rollback config enables it. The emission mirrors the main +// orchestrate workflow's repository_dispatch block (Generator.writeExtraTriggers) +// so the two are byte-consistent. When the trigger is absent, nothing is written +// and the workflow stays workflow_dispatch-only. +func (g *RollbackGenerator) writeRepositoryDispatchTrigger(sb *strings.Builder) { + rd := g.dispatchTrigger() + if rd == nil { + return + } + sb.WriteString(" repository_dispatch:\n") + if len(rd.Types) > 0 { + sb.WriteString(" types:\n") + for _, t := range rd.Types { + fmt.Fprintf(sb, " - %s\n", t) + } + } +} + // writeConcurrency serializes rollback runs so concurrent state writes cannot // interleave. The default group keys on the workflow; an explicit config group // overrides it, mirroring the promote generator. @@ -207,9 +257,9 @@ func (g *RollbackGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" - name: Resolve Target\n") sb.WriteString(" id: preflight\n") sb.WriteString(" env:\n") - sb.WriteString(" ENVIRONMENT: ${{ github.event.inputs.environment }}\n") - sb.WriteString(" TARGET: ${{ github.event.inputs.target }}\n") - sb.WriteString(" DEPLOYABLE: ${{ github.event.inputs.deployable }}\n") + fmt.Fprintf(sb, " ENVIRONMENT: ${{ %s }}\n", g.paramRead("environment")) + fmt.Fprintf(sb, " TARGET: ${{ %s }}\n", g.paramRead("target")) + fmt.Fprintf(sb, " DEPLOYABLE: ${{ %s }}\n", g.paramRead("deployable")) sb.WriteString(" run: |\n") sb.WriteString(" cascade rollback preflight \\\n") fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) @@ -224,10 +274,28 @@ func (g *RollbackGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString("\n") } +// paramReadExpr returns the parameter read for use inside a larger GitHub +// Actions expression (a comparison or boolean operand). In the baseline it is +// the bare github.event.inputs.; when the repository_dispatch trigger is +// enabled the coalescing "inputs || client_payload" is wrapped in parentheses so +// the surrounding operator (e.g. != 'true', == '') binds to the whole coalesced +// value rather than only the client_payload half. +func (g *RollbackGenerator) paramReadExpr(name string) string { + if g.dispatchTrigger() != nil { + return "(" + g.paramRead(name) + ")" + } + return g.paramRead(name) +} + // rollbackDeployGuard is the if-condition gating a rollback deploy job: not a // dry run, and either no deployable filter or a filter naming this deployable. -func rollbackDeployGuard(deployName string) string { - return fmt.Sprintf("${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.deployable == '' || github.event.inputs.deployable == '%s') }}", deployName) +// The dry_run and deployable reads coalesce client_payload when the +// repository_dispatch trigger is enabled, so an external signal honors the same +// dry-run and deployable scoping the manual path does. +func (g *RollbackGenerator) rollbackDeployGuard(deployName string) string { + dryRun := g.paramReadExpr("dry_run") + deployable := g.paramReadExpr("deployable") + return fmt.Sprintf("${{ %s != 'true' && (%s == '' || %s == '%s') }}", dryRun, deployable, deployable, deployName) } // writeDeployJobs emits one deploy job per configured deploy, re-running the same @@ -244,7 +312,7 @@ func (g *RollbackGenerator) writeDeployJobs(sb *strings.Builder) { fmt.Fprintf(sb, " deploy-%s:\n", d.Name) fmt.Fprintf(sb, " name: Deploy %s\n", d.Name) sb.WriteString(" needs: [preflight]\n") - fmt.Fprintf(sb, " if: %s\n", rollbackDeployGuard(d.Name)) + fmt.Fprintf(sb, " if: %s\n", g.rollbackDeployGuard(d.Name)) // Reusable (uses:) deploy: thread the resolved env and SHA via with:. The // environment name is carried as an input; GitHub Environment protection @@ -285,7 +353,7 @@ func (g *RollbackGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" finalize:\n") sb.WriteString(" name: Finalize\n") fmt.Fprintf(sb, " needs: %s\n", needsStr) - sb.WriteString(" if: always() && needs.preflight.result == 'success' && github.event.inputs.dry_run != 'true'\n") + fmt.Fprintf(sb, " if: always() && needs.preflight.result == 'success' && %s != 'true'\n", g.paramReadExpr("dry_run")) sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") writeMintSteps(sb, g.config, " ", seamRelease, seamState) @@ -302,7 +370,7 @@ func (g *RollbackGenerator) writeFinalizeJob(sb *strings.Builder) { // marks the env diverged while a deployable-scoped rollback touches only that // deployable. Without this, a deployable-scoped dispatch would resolve and // deploy one deployable but finalize the whole environment. - sb.WriteString(" DEPLOYABLE: ${{ github.event.inputs.deployable }}\n") + fmt.Fprintf(sb, " DEPLOYABLE: ${{ %s }}\n", g.paramRead("deployable")) // Thread each deploy job's conclusion in as DEPLOY_RESULT_ so the CLI // can gate the state write on actual deploy success. Deploy jobs only exist // when at least one environment is configured. diff --git a/internal/generate/rollback_test.go b/internal/generate/rollback_test.go index 28b0341..1d4370b 100644 --- a/internal/generate/rollback_test.go +++ b/internal/generate/rollback_test.go @@ -1,11 +1,15 @@ package generate import ( + "os" + "os/exec" + "path/filepath" "strings" "testing" "github.com/stablekernel/cascade/internal/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // rollbackTestConfig builds a multi-env config with a single deploy that has a @@ -138,6 +142,113 @@ func TestRollbackGenerator_PreflightBeforeDeployBeforeFinalize(t *testing.T) { assert.Greater(t, finalizeIdx, deployIdx) } +// rollbackDispatchConfig returns rollbackTestConfig with the opt-in +// repository_dispatch trigger enabled, carrying a neutral event type. +func rollbackDispatchConfig() *config.TrunkConfig { + cfg := rollbackTestConfig() + cfg.Rollback = &config.RollbackConfig{ + RepositoryDispatch: &config.RepositoryDispatchTrigger{ + Types: []string{"rollback-requested"}, + }, + } + return cfg +} + +// TestRollbackGenerator_OffStateByteIdentical proves that absent the opt-in +// (Rollback nil), the generated workflow is byte-identical to the baseline and +// carries no repository_dispatch trigger or client_payload coalescing. +func TestRollbackGenerator_OffStateByteIdentical(t *testing.T) { + baseline, err := NewRollbackGenerator(rollbackTestConfig(), "").Generate() + assert.NoError(t, err) + + // A Rollback block with a nil RepositoryDispatch is still "off". + cfg := rollbackTestConfig() + cfg.Rollback = &config.RollbackConfig{} + withEmpty, err := NewRollbackGenerator(cfg, "").Generate() + assert.NoError(t, err) + + assert.Equal(t, baseline, withEmpty, "an empty rollback block must not change the output") + assert.NotContains(t, baseline, "repository_dispatch") + assert.NotContains(t, baseline, "client_payload") +} + +// TestRollbackGenerator_DispatchTriggerEmitted proves the opt-in adds a +// repository_dispatch trigger (with the configured event types) under on: while +// keeping workflow_dispatch intact. +func TestRollbackGenerator_DispatchTriggerEmitted(t *testing.T) { + content, err := NewRollbackGenerator(rollbackDispatchConfig(), "").Generate() + assert.NoError(t, err) + + assert.Contains(t, content, "workflow_dispatch:") + assert.Contains(t, content, " repository_dispatch:\n") + assert.Contains(t, content, " types:\n") + assert.Contains(t, content, " - rollback-requested\n") + + // repository_dispatch must sit under on:, before the jobs block. + onIdx := strings.Index(content, "\non:\n") + dispatchIdx := strings.Index(content, " repository_dispatch:") + jobsIdx := strings.Index(content, "\njobs:\n") + assert.Greater(t, dispatchIdx, onIdx) + assert.Greater(t, jobsIdx, dispatchIdx) +} + +// TestRollbackGenerator_PreflightCoalescesReads proves the preflight env block +// coalesces github.event.inputs.* with github.event.client_payload.* when the +// dispatch trigger is enabled, so both trigger paths resolve the same target. +func TestRollbackGenerator_PreflightCoalescesReads(t *testing.T) { + content, err := NewRollbackGenerator(rollbackDispatchConfig(), "").Generate() + assert.NoError(t, err) + + assert.Contains(t, content, "ENVIRONMENT: ${{ github.event.inputs.environment || github.event.client_payload.environment }}") + assert.Contains(t, content, "TARGET: ${{ github.event.inputs.target || github.event.client_payload.target }}") + assert.Contains(t, content, "DEPLOYABLE: ${{ github.event.inputs.deployable || github.event.client_payload.deployable }}") +} + +// TestRollbackGenerator_GuardsCoalesceReads proves the deploy-guard, finalize +// gate, and finalize DEPLOYABLE read all coalesce client_payload when the +// dispatch trigger is enabled, so an external signal drives the dry_run and +// deployable scoping the manual path does. +func TestRollbackGenerator_GuardsCoalesceReads(t *testing.T) { + content, err := NewRollbackGenerator(rollbackDispatchConfig(), "").Generate() + assert.NoError(t, err) + + // dry_run guard and deployable filter on the deploy job. + assert.Contains(t, content, "github.event.inputs.dry_run || github.event.client_payload.dry_run") + assert.Contains(t, content, "github.event.inputs.deployable || github.event.client_payload.deployable") + // finalize DEPLOYABLE env read. + assert.Contains(t, content, "DEPLOYABLE: ${{ github.event.inputs.deployable || github.event.client_payload.deployable }}") +} + +// TestRollbackGenerator_DispatchActionlint runs actionlint over the rollback +// workflow generated with the repository_dispatch trigger enabled, proving the +// emitted trigger and the coalesced client_payload expressions are valid. Skipped +// when actionlint is not installed so the suite stays hermetic. +func TestRollbackGenerator_DispatchActionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + content, err := NewRollbackGenerator(rollbackDispatchConfig(), t.TempDir()).Generate() + require.NoError(t, err) + + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + path := filepath.Join(wfDir, "cascade-rollback.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + writeDeployReusableStub(t, dir, content) + + cmd := exec.Command(bin, "-shellcheck=", path) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues:\n%s", string(out)) +} + // finalizeNeedsLine returns the needs: line of the finalize job. func finalizeNeedsLine(t *testing.T, content string) string { t.Helper() diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 4c6693d..8d34cb7 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -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", @@ -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, diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 4c6693d..8d34cb7 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -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", @@ -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,