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
14 changes: 14 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" },
"deployments": { "$ref": "#/definitions/deploymentsConfig" },
"pin_mode": {
"type": "string",
"enum": ["tag", "sha"],
Expand Down Expand Up @@ -667,6 +668,15 @@
"comment": { "type": "boolean" }
}
},
"deploymentsConfig": {
"type": "object",
"additionalProperties": false,
"description": "Opt-in GitHub Deployments API integration. When enabled, the finalize job creates a Deployment per target environment and reports in_progress then success/failure status after each deploy.",
"properties": {
"enabled": { "type": "boolean", "description": "Activates GitHub Deployments API reporting in the finalize job." },
"keep_prior_active": { "type": "boolean", "description": "Prevents GitHub from auto-inactivating prior deployments for the same environment (auto_inactive:false). Default false relies on native auto-inactivation." }
}
},
"telemetryConfig": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -731,6 +741,10 @@
"type": "array",
"items": { "type": "string" },
"description": "Expected env-scoped variable NAMES (names only, never values)."
},
"environment_url": {
"type": "string",
"description": "URL of the deployed environment, reported to GitHub Deployments API status updates."
}
}
},
Expand Down
15 changes: 8 additions & 7 deletions docs/src/content/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,19 +564,19 @@ For satellite repos with notify config:

### What cascade does today

The generator emits an `environment: <name>` key on each deploy job whenever the manifest includes an `environments` list. That single key is enough for GitHub Actions to attach deployment records, honour required-reviewer gates, apply wait timers, and scope environment secrets. You configure all of that inside GitHub, not in the manifest. No cascade code calls the Deployments REST API or the Environments REST API directly.
The generator emits an `environment: <name>` key on each deploy job whenever the manifest includes an `environments` list. That single key is enough for GitHub Actions to attach deployment records, honour required-reviewer gates, apply wait timers, and scope environment secrets. You configure all of that inside GitHub, not in the manifest.

### What is deferred
When you opt in with `deployments.enabled: true`, the finalize job also reports deployment status through the Deployments API: it calls `POST /repos/{owner}/{repo}/deployments` to create a Deployment for the runtime-selected environment, `POST /repos/{owner}/{repo}/deployments/{id}/statuses` to mark it `in_progress`, then a terminal `success` or `failure` status once the deploy callbacks finish. See [Native deployments](/cascade/configuration/#native-deployments-opt-in) for the toggle and the per-environment `environment_url`.

Two capabilities are intentionally out of scope for v1:
### What is deferred

- Programmatic Deployments API status. cascade does not call `POST /repos/{owner}/{repo}/deployments` or `POST /repos/{owner}/{repo}/deployments/{id}/statuses`. GitHub Actions creates these records automatically when a job carries `environment:`, so adopters get deployment records without cascade owning that call.
One capability remains out of scope for v1:

- Environments REST configuration sync. cascade does not CALL the Environments REST API: it never reads or writes environment protection rules (required reviewers, wait timers, branch policies) over the wire. The manifest can now EXPRESS that configuration, and `cascade environments` emits it as an operator-appliable file (apply with `gh api` or Terraform), but applying it stays an operator step. cascade emits; the operator applies.

### Why deferred

Keeping cascade out of these APIs in v1 bounds the surface area and avoids coupling the tool to GitHub API semantics that are still evolving. The auto-created deployment records from `environment:` already cover the common case. Adding programmatic control before an adopter needs it would buy complexity and nothing else. If those APIs change shape, cascade would have to track the change even though nothing in v1 depends on them.
Keeping cascade out of the Environments REST API in v1 bounds the surface area and avoids coupling the tool to GitHub API semantics that are still evolving. Adding programmatic control before an adopter needs it would buy complexity and nothing else. If that API changes shape, cascade would have to track the change even though nothing in v1 depends on it.

### How the design reserves the extension points

Expand All @@ -597,19 +597,20 @@ config:
tag_patterns: [v*] # custom policy only
secrets: [MY_SECRET] # expected env-scoped secret names
variables: [REGION] # expected env-scoped variable names
environment_url: https://... # reported on the Deployment status (native deployments)
```

The protection fields (`required_reviewers`, `wait_timer`, `branch_policy`, `branch_patterns`, `tag_patterns`) and the expected `secrets` and `variables` names are real, additive fields, not reserved placeholders. `cascade environments` reads them and emits an operator-appliable file (see [environments](/cascade/cli-reference/#environments)). cascade still never calls the REST API: it forms the PUT body it can fully express from the manifest and surfaces the rest, including the reviewer slugs and the secret and variable names, under `operator_todo` for the operator to apply.

The `environments` list stays a plain ordered `[]string`; the separate `environment_config` map carries per-env settings. Adding fields under `environment_config.<name>` is additive and never touches the ordering semantics of `environments`. A manifest that omits `environment_config` entirely is valid and equivalent to today's behaviour.

**Single finalize seam.** The `orchestrate.Finalize` and `promote.Finalize` functions are the only places that write state after a deployment completes. A future Deployments API call attaches at one of those two points, not scattered across the generator. That code constraint is already in place.
**Single finalize seam.** The `orchestrate.Finalize` and `promote.Finalize` functions are the only places that write state after a deployment completes. The Deployments API status reporting attaches at exactly those two points, not scattered across the generator.

**Generator delegates environment semantics to GitHub.** Because the generator emits `environment:` and nothing more, it does not embed logic about what that environment means. Programmatic status reporting slots in at finalize time; Environments REST configuration sync is a separate operational concern that never needs to touch the generator.

### Forward-compatibility guarantee

Both capabilities, when they arrive, will follow the same additive-only policy described in [Versioning & Schema](/cascade/versioning/): new optional fields under `environment_config.<name>`, new optional top-level blocks if needed, and no removal or re-typing of existing fields. Neither will require a `schema_version` bump. Manifests that do not opt in to the new fields continue to work exactly as they do today.
Environments REST configuration sync, when it arrives, will follow the same additive-only policy described in [Versioning & Schema](/cascade/versioning/): new optional fields under `environment_config.<name>`, new optional top-level blocks if needed, and no removal or re-typing of existing fields. It will not require a `schema_version` bump. The Deployments API status reporting already shipped under that same policy: `deployments` and `environment_url` are additive opt-in fields that did not bump the schema version. Manifests that do not opt in to the new fields continue to work exactly as they do today.

## Testing Strategy

Expand Down
30 changes: 30 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,36 @@ Behavior:
- **Trusted PR resolution.** The companion derives the target pull-request number only from trusted `workflow_run` run metadata (the source run's `pull_requests` array, or a head-SHA lookup for fork pull requests), never from the artifact the pull-request job uploads. A fork therefore cannot redirect the comment at another pull request.
- **cascade-owned.** Both files carry the cascade-generated marker, so `cascade verify` itself tracks them: edit them by hand and they are reported as drift; remove the toggle and they are reported as orphans.

### Native deployments (opt-in)

Set `deployments.enabled: true` and the finalize job reports deployment status through the [GitHub Deployments API](https://docs.github.com/en/rest/deployments/deployments). It creates a Deployment for the environment selected at run time, marks it `in_progress`, then reports a terminal `success` or `failure` status once the deploy callbacks finish. Pair it with a per-environment `environment_url` so the Deployment status links straight to the running environment.

```yaml
ci:
config:
environments: [production]
deployments:
enabled: true
keep_prior_active: false
environment_config:
production:
environment_url: "https://app.example.com"
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `deployments.enabled` | bool | false | Create a Deployment and report status from the finalize job |
| `deployments.keep_prior_active` | bool | false | Set `auto_inactive: false` so GitHub leaves prior deployments for the same environment Active. Default relies on GitHub's native auto-inactivation |
| `environment_config.<env>.environment_url` | string | "" | URL reported on the Deployment status for that environment |

Behavior:

- **Status transition model.** The finalize job runs after every deploy callback, so it owns the full lifecycle: create the Deployment, set `in_progress`, then set `success` or `failure` based on whether every deploy callback succeeded. The terminal status step runs under `always()` so a failed deploy still reports `failure` instead of leaving the Deployment stuck at `in_progress`.
- **Per-environment URL.** `environment_url` is resolved at run time from `environment_config.<env>.environment_url` for the environment being deployed. Environments without a configured URL report an empty URL.
- **Guarded to real GitHub.** Every Deployments API step carries an `if: ${{ github.server_url == 'https://github.com' }}` guard, so on act or gitea (which have no Deployments API) the steps are skipped and the workflow stays runnable.
- **Least-privilege scope.** The toggle adds `deployments: write` to the workflow's top-level permissions only when enabled; the OFF-state output is unchanged.
- **Opt-in and additive.** Omit `deployments` and nothing is emitted. The field did not bump `schema_version`.

## State Section

The `state` section tracks deployment state per environment plus a synthetic `release` slot. The framework manages it automatically. Do not hand-edit.
Expand Down
5 changes: 5 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ type Config struct {
// the generated manifest untouched, so a scenario can enable the generated PR
// drift-check workflow and its fork-safe comment companion (#229).
DriftCheck map[string]any `yaml:"drift_check,omitempty"`
// Deployments carries the opt-in native GitHub Deployments block (enabled,
// keep_prior_active) through to the generated manifest untouched, so a
// 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"`
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
66 changes: 66 additions & 0 deletions e2e/scenarios/31-native-deployments.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: "Native Deployments"
description: |
Exercises the opt-in GitHub Deployments API integration
(config.deployments.enabled) and the per-environment environment_url. When
enabled, the finalize job creates a GitHub Deployment for the runtime-selected
environment, marks it in_progress, then reports a terminal success or failure
status. The finalize job also requests deployments: write so it can create the
Deployment and post status updates.

Each Deployments API step is guarded by github.server_url == 'https://github.com',
so on act/gitea (which has no Deployments API) the steps are skipped and the
workflow stays runnable. Live Deployments API assertions are therefore out of
scope for this scenario; it proves the emitted steps, the deployments: write
permission, the environment_url wiring, and the server_url guard are present,
schema-valid, and byte-stable. The commit step generates the workflows and
asserts the deployment steps are emitted and guarded; the verify step then
regenerates and proves the output is byte-identical with no drift.

config:
trunk_branch: main
environments: [production]
deployments:
enabled: true
keep_prior_active: true
environment_config:
production:
environment_url: "https://app.example.com"
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["src/**"]

steps:
- name: "Seed a minimal source tree; assert deployment steps are emitted and guarded"
action: commit
commit:
message: "seed source"
files:
src/main.go: |
package main

func main() {}
expect:
workflow_files:
- path: ".github/workflows/orchestrate.yaml"
contains:
- " deployments: write"
- " - name: Create deployment"
- " if: ${{ github.server_url == 'https://github.com' }}"
- " deployment_id=$(gh api repos/${{ github.repository }}/deployments \\"
- " --field auto_inactive=false \\"
- " - name: Set deployment in_progress"
- " --field state=in_progress"
- " - name: Set deployment status"
- " if: ${{ github.server_url == 'https://github.com' && always() }}"
- " production) environment_url=\"https://app.example.com\" ;;"

- name: "Regenerate and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0
46 changes: 46 additions & 0 deletions internal/config/native_deployments_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package config

import "testing"

// TestParseDeployments proves the opt-in deployments block and the per-env
// environment_url field parse from a manifest.
func TestParseDeployments(t *testing.T) {
cfg := parseInline(t, `
trunk_branch: main
environments: [production]
deployments:
enabled: true
keep_prior_active: true
environment_config:
production:
environment_url: "https://app.example.com"
`)
if cfg.Deployments == nil || !cfg.Deployments.Enabled || !cfg.Deployments.KeepPriorActive {
t.Fatalf("deployments: %#v", cfg.Deployments)
}
ec, ok := cfg.EnvironmentConfig["production"]
if !ok {
t.Fatalf("environment_config missing production: %#v", cfg.EnvironmentConfig)
}
if ec.EnvironmentURL != "https://app.example.com" {
t.Fatalf("environment_url: %q", ec.EnvironmentURL)
}
}

// TestDeploymentsValidatesAtCurrentSchemaVersion proves the deployments toggle
// and environment_url are additive: a manifest that sets them validates cleanly
// at CurrentSchemaVersion, confirming the schema version was not bumped.
func TestDeploymentsValidatesAtCurrentSchemaVersion(t *testing.T) {
cfg := &TrunkConfig{
SchemaVersion: CurrentSchemaVersion,
TrunkBranch: "main",
Environments: []string{"production"},
Deployments: &DeploymentsConfig{Enabled: true, KeepPriorActive: true},
EnvironmentConfig: map[string]EnvironmentConfig{
"production": {EnvironmentURL: "https://app.example.com"},
},
}
for _, e := range Validate(cfg) {
t.Fatalf("unexpected validation error for deployments at current schema version: %s", e)
}
}
14 changes: 14 additions & 0 deletions internal/config/schema_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,18 @@ type DriftCheckConfig struct {
Comment bool `yaml:"comment,omitempty" json:"comment,omitempty"`
}

// DeploymentsConfig configures opt-in GitHub Deployments API integration.
// When enabled, the finalize job creates a Deployment per target environment
// and reports in_progress then success/failure status after each deploy.
type DeploymentsConfig struct {
// Enabled activates GitHub Deployments API reporting in the finalize job.
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// KeepPriorActive prevents GitHub from auto-inactivating prior deployments
// for the same environment. Maps to auto_inactive:false in the Deployments API.
// Default false relies on GitHub native auto-inactivation.
KeepPriorActive bool `yaml:"keep_prior_active,omitempty" json:"keep_prior_active,omitempty"`
}

// Pin mode constants.
const (
PinModeTag = "tag"
Expand Down Expand Up @@ -365,6 +377,8 @@ type EnvironmentConfig struct {
// environment. Names only, never values; the operator creates them out of
// band.
Variables []string `yaml:"variables,omitempty" json:"variables,omitempty"`
// EnvironmentURL is the URL of the deployed environment, reported to GitHub Deployments API status updates.
EnvironmentURL string `yaml:"environment_url,omitempty" json:"environment_url,omitempty"`
}

// Environment branch-policy mode constants. They map onto GitHub's
Expand Down
Loading
Loading