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
2 changes: 2 additions & 0 deletions cmd/cascade/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/stablekernel/cascade/internal/changelog"
"github.com/stablekernel/cascade/internal/changes"
"github.com/stablekernel/cascade/internal/config"
"github.com/stablekernel/cascade/internal/environments"
"github.com/stablekernel/cascade/internal/external"
"github.com/stablekernel/cascade/internal/generate"
"github.com/stablekernel/cascade/internal/globals"
Expand Down Expand Up @@ -74,6 +75,7 @@ change detection, and changelog generation.`,
rootCmd.AddCommand(config.NewCommand())
rootCmd.AddCommand(changes.NewCommand())
rootCmd.AddCommand(changelog.NewCommand())
rootCmd.AddCommand(environments.NewCommand())
rootCmd.AddCommand(external.NewCommand())
rootCmd.AddCommand(generate.NewCommand())
rootCmd.AddCommand(verify.NewCommand())
Expand Down
38 changes: 37 additions & 1 deletion docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -667,11 +667,47 @@
"environmentConfig": {
"type": "object",
"additionalProperties": false,
"description": "Per-environment settings block.",
"description": "Per-environment settings block. All fields are optional and additive; the cascade environments command emits these as an operator-appliable GitHub Environments REST config.",
"properties": {
"gha_environment": {
"type": "string",
"description": "Maps this environment to a GitHub Environment (deployment records, required reviewers, wait timers, env-scoped secrets)."
},
"required_reviewers": {
"type": "array",
"items": { "type": "string" },
"description": "User or team slugs that may approve a deployment to this environment (slugs, not numeric ids)."
},
"wait_timer": {
"type": "integer",
"minimum": 0,
"maximum": 43200,
"description": "Delay in minutes before a job targeting this environment runs (0 to 43200)."
},
"branch_policy": {
"type": "string",
"enum": ["protected", "custom", "all"],
"description": "Which branches may deploy: protected (protected branches only), custom (BranchPatterns/TagPatterns), or all (no restriction)."
},
"branch_patterns": {
"type": "array",
"items": { "type": "string" },
"description": "Branch name patterns allowed to deploy when branch_policy is custom."
},
"tag_patterns": {
"type": "array",
"items": { "type": "string" },
"description": "Tag name patterns allowed to deploy when branch_policy is custom."
},
"secrets": {
"type": "array",
"items": { "type": "string" },
"description": "Expected env-scoped secret NAMES (names only, never values)."
},
"variables": {
"type": "array",
"items": { "type": "string" },
"description": "Expected env-scoped variable NAMES (names only, never values)."
}
}
},
Expand Down
21 changes: 13 additions & 8 deletions docs/src/content/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ For satellite repos with notify config:
2. **Custom Release** - Override with `release.tag` for external tools
3. **Custom Inputs** - Pass arbitrary inputs via `inputs`/`env_inputs`
4. **Output Chaining** - Outputs auto-discovered and passed to dependents
5. **GitHub Environments** - `environment_config` reserved shape; see [GitHub Deployments API and Environments REST](#github-deployments-api-and-environments-rest) below
5. **GitHub Environments** - `environment_config` per-env settings emitted by `cascade environments`; see [GitHub Deployments API and Environments REST](#github-deployments-api-and-environments-rest) below

## GitHub Deployments API and Environments REST

Expand All @@ -572,7 +572,7 @@ Two capabilities are intentionally out of scope for v1:

- 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.

- Environments REST configuration sync. cascade does not read or write environment protection rules (required reviewers, wait timers, branch policies) via the REST API. That configuration lives in GitHub today.
- 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

Expand All @@ -582,20 +582,25 @@ Keeping cascade out of these APIs in v1 bounds the surface area and avoids coupl

The schema already carries the hooks needed to add both capabilities later without a breaking change:

**`environment_config` reserved shape.** The manifest schema reserves an `environment_config` block at the `config:` level, keyed by environment name:
**`environment_config` shape.** The manifest schema carries an `environment_config` block at the `config:` level, keyed by environment name:

```yaml
config:
environments: [dev, test, prod] # ordered list (source of truth), unchanged
environment_config: # reserved; omitting it is valid today
environment_config: # optional; omitting it is valid
prod:
gha_environment: production # maps to the GHA environment name
# future additive fields:
# required_reviewers: [team/ops]
# wait_timer: 10
# branch_policy: protected
required_reviewers: [team/ops] # user/team slugs
wait_timer: 10 # minutes (0..43200)
branch_policy: protected # protected | custom | all
branch_patterns: [release/*] # custom policy only
tag_patterns: [v*] # custom policy only
secrets: [MY_SECRET] # expected env-scoped secret names
variables: [REGION] # expected env-scoped variable names
```

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.
Expand Down
64 changes: 64 additions & 0 deletions docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,70 @@ This command complements the hotfix branch-protection advisory (see [Hotfix work
| `--branch` | string | `main` | Branch the protection targets (labels the guidance note only; does not change the required contexts) |
| `--output`, `-o` | string | stdout | Write to this path instead of stdout (`-` also means stdout) |

### environments

Emit a per-environment configuration file an operator applies to GitHub's Environments REST API. cascade emits the file; the operator applies it. cascade never calls the GitHub API.

```bash
cascade environments
```

The output is a wrapper. The top-level `environments` is an array with one entry per manifest environment, in the manifest's `environments` order. Each entry has:

- `name` is the cascade environment name.
- `gha_environment` is the GitHub Environment to configure; it defaults to `name`.
- `environment` is the exact body to PUT to the environments API.
- `operator_todo` is companion guidance and is NOT part of the PUT body.

Apply it by sending only the `.environment` object per entry:

```bash
cascade environments | jq -c '.environments[] | {gha_environment, environment}' | while read -r row; do
env=$(jq -r .gha_environment <<<"$row")
jq .environment <<<"$row" | gh api -X PUT "repos/my-org/my-app/environments/$env" --input -
done
```

The per-environment settings come from the manifest under `config.environment_config.<env>`:

```yaml
config:
environments: [dev, test, prod]
environment_config:
prod:
gha_environment: production
required_reviewers: [team/ops]
wait_timer: 10
branch_policy: protected
secrets: [MY_SECRET]
variables: [REGION]
```

#### What the body carries, and what is operator guidance

The `.environment` body holds only the fields cascade can fully form from the manifest:

- `wait_timer` in minutes (0..43200).
- `deployment_branch_policy`, mapped from the manifest `branch_policy`: `protected` becomes `{protected_branches: true, custom_branch_policies: false}`; `custom` becomes `{protected_branches: false, custom_branch_policies: true}`; `all` or unset becomes `null`, meaning all branches.

Everything else cascade cannot fully form from the manifest is surfaced under `operator_todo` so the operator can finish it:

- `operator_todo.required_reviewers` lists user and team slugs, NOT the body. The REST API requires a numeric reviewer id that the manifest does not carry, so the operator resolves each slug to an id and adds it to the body's `reviewers` array.
- `operator_todo.secrets` and `operator_todo.variables` list the expected env-scoped secret and variable names. cascade emits names only, never values; the operator creates them with values through the environment-secrets and environment-variables APIs.
- `branch_patterns` and `tag_patterns` (custom policy only) are created through the deployment-branch-policies API and are surfaced under `operator_todo`.

The output is deterministic: the same manifest yields byte-identical output, and environments follow the manifest order.

This is the sibling of the [branch-protection](#branch-protection) command, using the same emit-a-config-file pattern (operator applies; cascade never calls the API).

#### Flags

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--config`, `-c` | string | auto-detect | Path to manifest file |
| `--manifest-key` | string | `ci` | Top-level key inside the manifest |
| `--output`, `-o` | string | stdout | Write to this path instead of stdout (`-` also means stdout) |

### manage-release

Manage GitHub releases.
Expand Down
21 changes: 9 additions & 12 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@ type Config struct {
// DispatchInput shape while preserving every key (type, options, default,
// description, required) across the marshal round-trip.
DispatchInputs map[string]map[string]any `yaml:"dispatch_inputs,omitempty"`
// EnvironmentConfig carries per-environment passthrough settings (currently
// gha_environment) into the generated manifest so the generator emits the
// job-level environment: key. Mirrors internal/config EnvironmentConfig.
// Keyed by env name so future per-env keys extend additively.
EnvironmentConfig map[string]EnvEnvironmentConfig `yaml:"environment_config,omitempty"`
// EnvironmentConfig carries per-environment settings (gha_environment plus the
// additive required_reviewers, wait_timer, branch_policy, branch_patterns,
// tag_patterns, secrets, and variables fields) into the generated manifest so
// the generator emits the job-level environment: key and the cascade
// environments command can emit the per-env config. A generic map per env keeps
// the harness decoupled from the generator's EnvironmentConfig struct while
// preserving every key across the marshal round-trip, so a scenario can declare
// any per-env field without a harness change. Keyed by env name.
EnvironmentConfig map[string]map[string]any `yaml:"environment_config,omitempty"`
// Validate, ValidateCheck, MergeQueue, PRPreview, Notify, and External carry
// the optional generator features through to the generated manifest untouched.
// Each uses a generic map (rather than a typed struct) so the harness stays
Expand Down Expand Up @@ -84,13 +88,6 @@ type Config struct {
Release map[string]any `yaml:"release,omitempty"`
}

// EnvEnvironmentConfig mirrors internal/config.EnvironmentConfig's gha_environment
// passthrough. Its own struct (not an inline map) so more per-env keys can be
// added later without touching call sites.
type EnvEnvironmentConfig struct {
GHAEnvironment string `yaml:"gha_environment,omitempty"`
}

// PublishConfig defines a publish callback invoked after a release is published
type PublishConfig struct {
Workflow string `yaml:"workflow"`
Expand Down
52 changes: 52 additions & 0 deletions e2e/scenarios/29-environment-config-emit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: "Environment Config Emit"
description: |
Exercises the additive per-environment fields under environment_config
(required_reviewers, wait_timer, branch_policy with branch_patterns and
tag_patterns, secrets, and variables). These fields are emit-on-demand: the
cascade environments command serializes them into an operator-appliable
environments.json, but the workflow generator does not consume them. The
scenario declares the enriched environment_config block, generates the
workflows, then regenerates and proves the output is byte-identical with no
drift, confirming the new fields parse and validate without changing
generation. secrets and variables are expected NAMES only, never values.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["src/**"]
environment_config:
prod:
gha_environment: production
required_reviewers: [octocat, team/ops]
wait_timer: 10
branch_policy: protected
secrets: [MY_SECRET, DB_PASSWORD]
variables: [REGION]
dev:
branch_policy: custom
branch_patterns: [main, "release/*"]
tag_patterns: ["v*"]

steps:
- name: "Seed a minimal source tree"
action: commit
commit:
message: "seed source"
files:
src/main.go: |
package main

func main() {}

- name: "Regenerate and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0
62 changes: 59 additions & 3 deletions internal/config/schema_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,16 +318,72 @@ type TelemetryWebhook struct {
SecretName string `yaml:"secret_name,omitempty" json:"secret_name,omitempty"`
}

// EnvironmentConfig is the reserved per-environment settings block, keyed by env
// name under config.environment_config. environments stays the ordered source
// of truth for env names; this block carries per-env settings without fanning
// EnvironmentConfig is the per-environment settings block, keyed by env name
// under config.environment_config. environments stays the ordered source of
// truth for env names; this block carries per-env settings without fanning
// names into multiple list fields.
//
// All fields are optional and additive: a manifest that omits them, or omits
// environment_config entirely, is valid and unchanged. The protection fields
// (required reviewers, wait timer, branch policy) and the expected secret and
// variable names map onto GitHub's Environments REST API so that the
// "cascade environments" command can emit an operator-appliable config file.
// cascade emits that file; the operator applies it (gh api / Terraform).
// cascade never calls the GitHub API.
type EnvironmentConfig struct {
// GHAEnvironment maps this env to a GitHub Environment (deployment records,
// required reviewers, wait timers, env-scoped secrets).
GHAEnvironment string `yaml:"gha_environment,omitempty" json:"gha_environment,omitempty"`
// RequiredReviewers lists the user or team slugs that may approve a
// deployment to this environment. These are slugs (for example "octocat" or
// "team/ops"), not GitHub numeric IDs: the Environments REST API requires a
// numeric reviewer id, so the emit command surfaces these slugs as operator
// guidance to resolve rather than as a directly-appliable reviewers array.
RequiredReviewers []string `yaml:"required_reviewers,omitempty" json:"required_reviewers,omitempty"`
// WaitTimer is the delay, in minutes, before a job targeting this
// environment runs. GitHub allows an integer between 0 and 43200 (30 days).
WaitTimer int `yaml:"wait_timer,omitempty" json:"wait_timer,omitempty"`
// BranchPolicy selects which branches may deploy to this environment. It
// maps to GitHub's deployment_branch_policy model: "protected" (only
// protected branches), "custom" (only branches matching BranchPatterns or
// tags matching TagPatterns), or "all" (no restriction). Empty means
// unspecified, which the emit command treats as "all".
BranchPolicy string `yaml:"branch_policy,omitempty" json:"branch_policy,omitempty"`
// BranchPatterns lists branch name patterns allowed to deploy when
// BranchPolicy is "custom". Each pattern is created via the Environments
// deployment-branch-policies API. Meaningful only when BranchPolicy is
// "custom".
BranchPatterns []string `yaml:"branch_patterns,omitempty" json:"branch_patterns,omitempty"`
// TagPatterns lists tag name patterns allowed to deploy when BranchPolicy is
// "custom". Meaningful only when BranchPolicy is "custom".
TagPatterns []string `yaml:"tag_patterns,omitempty" json:"tag_patterns,omitempty"`
// Secrets lists the EXPECTED env-scoped secret NAMES for this environment.
// These are names only: cascade never stores or emits secret values. The
// operator creates the named secrets out of band.
Secrets []string `yaml:"secrets,omitempty" json:"secrets,omitempty"`
// Variables lists the EXPECTED env-scoped variable NAMES for this
// environment. Names only, never values; the operator creates them out of
// band.
Variables []string `yaml:"variables,omitempty" json:"variables,omitempty"`
}

// Environment branch-policy mode constants. They map onto GitHub's
// deployment_branch_policy model: protected_branches, custom_branch_policies,
// or null (all branches).
const (
// EnvBranchPolicyProtected restricts deployments to protected branches.
EnvBranchPolicyProtected = "protected"
// EnvBranchPolicyCustom restricts deployments to branches and tags matching
// the configured patterns.
EnvBranchPolicyCustom = "custom"
// EnvBranchPolicyAll places no branch restriction on deployments.
EnvBranchPolicyAll = "all"
)

// MaxWaitTimerMinutes is the largest wait_timer GitHub accepts: 43200 minutes
// (30 days).
const MaxWaitTimerMinutes = 43200

// DeployTarget is the reserved GitOps-mirror deploy variant. It complements,
// not replaces, the External/Notify cross-repo dispatch model.
type DeployTarget struct {
Expand Down
Loading
Loading