From 170810fd3eb58a29fa0fcf0859292107d924374a Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 21 Jun 2026 05:40:31 -0400 Subject: [PATCH] feat: emit environments.json from the manifest Signed-off-by: Joshua Temple --- cmd/cascade/main.go | 2 + docs/public/manifest.schema.json | 38 ++- docs/src/content/docs/architecture.md | 21 +- docs/src/content/docs/cli-reference.md | 64 +++++ e2e/harness/scenario.go | 21 +- e2e/scenarios/29-environment-config-emit.yaml | 52 ++++ internal/config/schema_v1.go | 62 ++++- internal/config/validate_environment_test.go | 248 ++++++++++++++++++ internal/config/validate_v1.go | 81 ++++++ internal/environments/command.go | 114 ++++++++ internal/environments/command_test.go | 121 +++++++++ internal/environments/payload.go | 198 ++++++++++++++ internal/environments/payload_test.go | 170 ++++++++++++ internal/schema/manifest.schema.json | 38 ++- schema/manifest.schema.json | 38 ++- 15 files changed, 1242 insertions(+), 26 deletions(-) create mode 100644 e2e/scenarios/29-environment-config-emit.yaml create mode 100644 internal/config/validate_environment_test.go create mode 100644 internal/environments/command.go create mode 100644 internal/environments/command_test.go create mode 100644 internal/environments/payload.go create mode 100644 internal/environments/payload_test.go diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index 744a506..c5285ef 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -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" @@ -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()) diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 11b3e22..21b76c8 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -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)." } } }, diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index ac23ea9..d9849c4 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -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 @@ -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 @@ -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.` 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. diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index efc373c..d6d77fa 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -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.`: + +```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. diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index 9c1747a..b13b75e 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -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 @@ -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"` diff --git a/e2e/scenarios/29-environment-config-emit.yaml b/e2e/scenarios/29-environment-config-emit.yaml new file mode 100644 index 0000000..63a9b89 --- /dev/null +++ b/e2e/scenarios/29-environment-config-emit.yaml @@ -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 diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 6431b3f..46dd286 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -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 { diff --git a/internal/config/validate_environment_test.go b/internal/config/validate_environment_test.go new file mode 100644 index 0000000..f2a73f5 --- /dev/null +++ b/internal/config/validate_environment_test.go @@ -0,0 +1,248 @@ +package config + +import "testing" + +// TestValidateEnvironmentConfigFields exercises the additive per-environment +// fields under environment_config. Validation is lenient and applies only when a +// field is present, so a manifest that omits these fields is never rejected. +// Secrets and variables are NAMES only and are checked for a safe name shape. +func TestValidateEnvironmentConfigFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + envConfig map[string]EnvironmentConfig + wantErr bool + errContains string + }{ + { + name: "nil environment_config is valid", + envConfig: nil, + wantErr: false, + }, + { + name: "empty per-env block is valid (shape only)", + envConfig: map[string]EnvironmentConfig{"prod": {}}, + wantErr: false, + }, + { + name: "full valid block", + envConfig: map[string]EnvironmentConfig{ + "prod": { + GHAEnvironment: "production", + RequiredReviewers: []string{"octocat", "team/ops"}, + WaitTimer: 10, + BranchPolicy: EnvBranchPolicyCustom, + BranchPatterns: []string{"main", "release/*"}, + TagPatterns: []string{"v*"}, + Secrets: []string{"MY_SECRET", "DB_PASSWORD"}, + Variables: []string{"REGION", "TIER"}, + }, + }, + wantErr: false, + }, + { + name: "wait_timer zero is valid", + envConfig: map[string]EnvironmentConfig{ + "prod": {WaitTimer: 0}, + }, + wantErr: false, + }, + { + name: "wait_timer at maximum is valid", + envConfig: map[string]EnvironmentConfig{ + "prod": {WaitTimer: MaxWaitTimerMinutes}, + }, + wantErr: false, + }, + { + name: "wait_timer above maximum is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {WaitTimer: MaxWaitTimerMinutes + 1}, + }, + wantErr: true, + errContains: "wait_timer must be between 0 and 43200 minutes", + }, + { + name: "negative wait_timer is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {WaitTimer: -1}, + }, + wantErr: true, + errContains: "wait_timer must be between 0 and 43200 minutes", + }, + { + name: "protected branch policy is valid", + envConfig: map[string]EnvironmentConfig{ + "prod": {BranchPolicy: EnvBranchPolicyProtected}, + }, + wantErr: false, + }, + { + name: "all branch policy is valid", + envConfig: map[string]EnvironmentConfig{ + "prod": {BranchPolicy: EnvBranchPolicyAll}, + }, + wantErr: false, + }, + { + name: "unknown branch policy is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {BranchPolicy: "sometimes"}, + }, + wantErr: true, + errContains: "branch_policy must be one of: protected, custom, all", + }, + { + name: "branch_patterns without custom policy is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {BranchPolicy: EnvBranchPolicyProtected, BranchPatterns: []string{"main"}}, + }, + wantErr: true, + errContains: "branch_patterns is only valid when branch_policy is custom", + }, + { + name: "tag_patterns without custom policy is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {TagPatterns: []string{"v*"}}, + }, + wantErr: true, + errContains: "tag_patterns is only valid when branch_policy is custom", + }, + { + name: "empty reviewer slug is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {RequiredReviewers: []string{""}}, + }, + wantErr: true, + errContains: "required_reviewers[0]", + }, + { + name: "whitespace reviewer slug is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {RequiredReviewers: []string{"team ops"}}, + }, + wantErr: true, + errContains: "required_reviewers[0]", + }, + { + name: "reviewer slug with too many segments is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {RequiredReviewers: []string{"my-org/team/ops"}}, + }, + wantErr: true, + errContains: "required_reviewers[0]", + }, + { + name: "secret name starting with a digit is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {Secrets: []string{"1SECRET"}}, + }, + wantErr: true, + errContains: "secrets[0]", + }, + { + name: "secret name with interpolation is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {Secrets: []string{"${{ secrets.X }}"}}, + }, + wantErr: true, + errContains: "secrets[0]", + }, + { + name: "variable name with whitespace is rejected", + envConfig: map[string]EnvironmentConfig{ + "prod": {Variables: []string{"BAD NAME"}}, + }, + wantErr: true, + errContains: "variables[0]", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := &TrunkConfig{ + Environments: []string{"prod"}, + EnvironmentConfig: tt.envConfig, + } + errs := validateEnvironmentConfig(cfg) + if tt.wantErr { + if len(errs) == 0 { + t.Fatalf("expected an error, got none") + } + if !hasErrContaining(errs, tt.errContains) { + t.Fatalf("expected error containing %q, got %v", tt.errContains, errs) + } + return + } + if len(errs) != 0 { + t.Fatalf("expected no errors, got %v", errs) + } + }) + } +} + +// TestParseEnvironmentConfigReservedFields asserts a manifest carrying the +// additive environment_config fields parses into the typed fields, validates at +// CurrentSchemaVersion, and does not bump schema_version. +func TestParseEnvironmentConfigReservedFields(t *testing.T) { + t.Parallel() + + cfg := parseInline(t, ` +environments: [dev, prod] +deploys: + - name: app + workflow: .github/workflows/deploy.yaml +environment_config: + prod: + gha_environment: production + required_reviewers: [octocat, team/ops] + wait_timer: 10 + branch_policy: custom + branch_patterns: [main, "release/*"] + tag_patterns: ["v*"] + secrets: [MY_SECRET, DB_PASSWORD] + variables: [REGION, TIER] +`) + + ec, ok := cfg.EnvironmentConfig["prod"] + if !ok { + t.Fatalf("environment_config.prod did not parse") + } + if ec.GHAEnvironment != "production" { + t.Fatalf("gha_environment: %q", ec.GHAEnvironment) + } + if got, want := len(ec.RequiredReviewers), 2; got != want { + t.Fatalf("required_reviewers len = %d, want %d", got, want) + } + if ec.WaitTimer != 10 { + t.Fatalf("wait_timer = %d, want 10", ec.WaitTimer) + } + if ec.BranchPolicy != EnvBranchPolicyCustom { + t.Fatalf("branch_policy = %q", ec.BranchPolicy) + } + if got, want := len(ec.BranchPatterns), 2; got != want { + t.Fatalf("branch_patterns len = %d, want %d", got, want) + } + if got, want := len(ec.TagPatterns), 1; got != want { + t.Fatalf("tag_patterns len = %d, want %d", got, want) + } + if got, want := len(ec.Secrets), 2; got != want { + t.Fatalf("secrets len = %d, want %d", got, want) + } + if got, want := len(ec.Variables), 2; got != want { + t.Fatalf("variables len = %d, want %d", got, want) + } + + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("expected no errors, got %v", errs) + } + if got := cfg.GetSchemaVersion(); got != CurrentSchemaVersion { + t.Fatalf("schema_version = %d, want %d (additive fields must not bump)", got, CurrentSchemaVersion) + } + if CurrentSchemaVersion != 1 { + t.Fatalf("CurrentSchemaVersion = %d, want 1 (additive fields must not bump)", CurrentSchemaVersion) + } +} diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 19b063c..69fee01 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -311,10 +311,91 @@ func validateConfigLevel(cfg *TrunkConfig) []string { } errs = append(errs, validateTelemetry(cfg.Telemetry)...) + errs = append(errs, validateEnvironmentConfig(cfg)...) return errs } +// validateEnvironmentConfig checks the additive per-environment fields under +// environment_config (required_reviewers, wait_timer, branch_policy and its +// patterns, secrets, variables). Every check is lenient and applies only when a +// field is present, so a manifest that omits these fields, or omits +// environment_config entirely, is never rejected. Secret and variable entries +// are NAMES only: they are checked for a safe name shape, never treated as +// credential values. The env-key-references-a-declared-environment check lives +// in validateConfigLevel and is not duplicated here. +func validateEnvironmentConfig(cfg *TrunkConfig) []string { + if len(cfg.EnvironmentConfig) == 0 { + return nil + } + var errs []string + for _, name := range sortedKeys(toEnvKeyed(cfg.EnvironmentConfig)) { + ec := cfg.EnvironmentConfig[name] + prefix := "environment_config." + name + + if ec.WaitTimer < 0 || ec.WaitTimer > MaxWaitTimerMinutes { + errs = append(errs, fmt.Sprintf("%s.wait_timer must be between 0 and %d minutes", prefix, MaxWaitTimerMinutes)) + } + + switch ec.BranchPolicy { + case "", EnvBranchPolicyProtected, EnvBranchPolicyCustom, EnvBranchPolicyAll: + // ok + default: + errs = append(errs, fmt.Sprintf("%s.branch_policy must be one of: protected, custom, all", prefix)) + } + if ec.BranchPolicy != EnvBranchPolicyCustom { + if len(ec.BranchPatterns) > 0 { + errs = append(errs, fmt.Sprintf("%s.branch_patterns is only valid when branch_policy is custom", prefix)) + } + if len(ec.TagPatterns) > 0 { + errs = append(errs, fmt.Sprintf("%s.tag_patterns is only valid when branch_policy is custom", prefix)) + } + } + + for i, r := range ec.RequiredReviewers { + if !safeReviewerSlug(r) { + errs = append(errs, fmt.Sprintf("%s.required_reviewers[%d] %q must be a non-empty user or team slug (optionally org/team) with no whitespace", prefix, i, r)) + } + } + + for i, s := range ec.Secrets { + if !safeSecretName(s) { + errs = append(errs, fmt.Sprintf("%s.secrets[%d] %q must be a valid GitHub Actions secret name (letters, digits, underscores; not starting with a digit)", prefix, i, s)) + } + } + for i, v := range ec.Variables { + if !safeSecretName(v) { + errs = append(errs, fmt.Sprintf("%s.variables[%d] %q must be a valid GitHub Actions variable name (letters, digits, underscores; not starting with a digit)", prefix, i, v)) + } + } + } + return errs +} + +// safeReviewerSlug reports whether s is a plausible GitHub reviewer slug: a +// non-empty, whitespace-free string of at most two slash-separated segments +// (a "user" slug or an "org/team" slug). It guards a name reference, not a +// credential, so it is deliberately permissive about the slug character set +// while rejecting empty, whitespace-only, and value-carrying shapes. +func safeReviewerSlug(s string) bool { + if s == "" { + return false + } + if strings.ContainsAny(s, " \t\n\r\f\v") { + return false + } + segments := strings.Split(s, "/") + if len(segments) > 2 { + return false + } + for _, seg := range segments { + if seg == "" { + return false + } + } + return true +} + // validateTelemetry checks only the newly reserved telemetry.webhook fields. // adapter is left unchecked on purpose: an arbitrary adapter string parses and // validates today, and the seam must stay additive, so no enum is enforced. The diff --git a/internal/environments/command.go b/internal/environments/command.go new file mode 100644 index 0000000..383a8c4 --- /dev/null +++ b/internal/environments/command.go @@ -0,0 +1,114 @@ +package environments + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" +) + +// Options configures an environments emit run. +type Options struct { + // ConfigPath is the manifest path; empty means auto-detect. + ConfigPath string + // ManifestKey is the key in the manifest file holding the CI config. + ManifestKey string + // Output is the destination path. Empty or "-" writes to stdout. + Output string +} + +// NewCommand creates the environments command. It emits 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. +func NewCommand() *cobra.Command { + var o Options + + cmd := &cobra.Command{ + Use: "environments", + Short: "Emit the per-environment config file for an operator to apply", + Long: `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. + +The output is a wrapper with one entry per manifest environment, in the +manifest's environments order: + + environments[].name the cascade environment name + environments[].gha_environment the GitHub Environment to configure + environments[].environment the body to PUT to the environments API + environments[].operator_todo companion guidance that is NOT part of the body + +Apply each entry by sending only its .environment object, for example: + + 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/OWNER/REPO/environments/$env" --input - + done + +The .environment body carries only the fields cascade can fully form from the +manifest (wait_timer and deployment_branch_policy). Required reviewers are listed +under operator_todo.required_reviewers as slugs because the REST API needs a +numeric reviewer id the manifest does not carry; resolve each slug to an id and +add it to the body's reviewers array. The expected env-scoped secret and variable +NAMES are listed under operator_todo.secrets and operator_todo.variables; cascade +emits names only, never values. Create them through the environment-secrets and +environment-variables APIs and set their values yourself.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return Run(o, cmd.OutOrStdout()) + }, + } + + cmd.Flags().StringVarP(&o.ConfigPath, "config", "c", "", "Path to config file (default: auto-detect .github/manifest.yaml)") + cmd.Flags().StringVar(&o.ManifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") + cmd.Flags().StringVarP(&o.Output, "output", "o", "", "Write to this path instead of stdout ('-' also means stdout)") + + return cmd +} + +// Run resolves the manifest, builds the payload, and writes it. When +// Options.Output is empty or "-", it writes to stdout (w); otherwise it writes to +// that file path. +func Run(o Options, stdout io.Writer) error { + configPath := o.ConfigPath + if configPath == "" { + configPath = config.FindConfigFile("") + } + + manifestKey := o.ManifestKey + if manifestKey == "" { + manifestKey = config.DefaultManifestKey + } + + cfg, err := config.ParseWithKey(configPath, manifestKey) + if err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + if errs := config.Validate(cfg); len(errs) > 0 { + return fmt.Errorf("config validation failed: %s", errs[0]) + } + + out, err := Marshal(Build(cfg)) + if err != nil { + return err + } + + if o.Output == "" || o.Output == "-" { + if _, werr := stdout.Write(out); werr != nil { + return fmt.Errorf("writing environments payload: %w", werr) + } + return nil + } + + if werr := os.WriteFile(o.Output, out, 0o644); werr != nil { + return fmt.Errorf("writing environments payload to %s: %w", o.Output, werr) + } + return nil +} diff --git a/internal/environments/command_test.go b/internal/environments/command_test.go new file mode 100644 index 0000000..3533475 --- /dev/null +++ b/internal/environments/command_test.go @@ -0,0 +1,121 @@ +package environments + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// minimalManifest is a hand-written manifest declaring two environments and an +// environment_config block exercising the additive fields, so the integration +// path runs the real parse -> validate -> build -> emit chain end to end. +const minimalManifest = `ci: + config: + trunk_branch: main + environments: + - staging + - production + deploys: + - name: services + workflow: .github/workflows/deploy.yaml + environment_config: + production: + gha_environment: prod + required_reviewers: [octocat, team/ops] + wait_timer: 15 + branch_policy: protected + secrets: [MY_SECRET] + variables: [REGION] +` + +// writeManifest writes minimalManifest into a temp .github/manifest.yaml and +// returns its path. +func writeManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + ghDir := filepath.Join(dir, ".github") + require.NoError(t, os.MkdirAll(ghDir, 0o755)) + path := filepath.Join(ghDir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(minimalManifest), 0o644)) + return path +} + +// TestCommand_EmitsPayloadToStdout runs the REAL environments command against a +// temp manifest, captures stdout, and asserts the emitted JSON parses and is in +// manifest order with the expected per-environment config. +// +// This change emits no workflow, so a Docker e2e/scenarios scenario (which +// drives committed workflow fixtures) is added separately for the parse + drift +// guarantee; this Go integration test drives the real command path, following +// the branch-protection command_test.go precedent. +func TestCommand_EmitsPayloadToStdout(t *testing.T) { + path := writeManifest(t) + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", path}) + require.NoError(t, cmd.Execute()) + + var p Payload + require.NoError(t, json.Unmarshal(out.Bytes(), &p)) + + require.Len(t, p.Environments, 2) + // Manifest order: staging before production. + assert.Equal(t, "staging", p.Environments[0].Name) + assert.Equal(t, "production", p.Environments[1].Name) + + prod := p.Environments[1] + assert.Equal(t, "prod", prod.GHAEnvironment) + assert.Equal(t, 15, prod.Environment.WaitTimer) + require.NotNil(t, prod.Environment.DeploymentBranchPolicy) + assert.True(t, prod.Environment.DeploymentBranchPolicy.ProtectedBranches) + assert.Equal(t, []string{"octocat", "team/ops"}, prod.OperatorTodo.RequiredReviewers) + assert.Equal(t, []string{"MY_SECRET"}, prod.OperatorTodo.Secrets) + assert.Equal(t, []string{"REGION"}, prod.OperatorTodo.Variables) +} + +// TestCommand_WritesToOutputFile confirms --output writes the payload to a file. +func TestCommand_WritesToOutputFile(t *testing.T) { + path := writeManifest(t) + outPath := filepath.Join(t.TempDir(), "environments.json") + + cmd := NewCommand() + cmd.SetArgs([]string{"--config", path, "--output", outPath}) + require.NoError(t, cmd.Execute()) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + + var p Payload + require.NoError(t, json.Unmarshal(data, &p)) + require.Len(t, p.Environments, 2) + assert.Equal(t, "staging", p.Environments[0].Name) +} + +// TestCommand_StdoutAndFileMatch confirms stdout and --output produce identical +// bytes for the same manifest (the drift guarantee through the command path). +func TestCommand_StdoutAndFileMatch(t *testing.T) { + path := writeManifest(t) + outPath := filepath.Join(t.TempDir(), "environments.json") + + stdoutCmd := NewCommand() + var stdout bytes.Buffer + stdoutCmd.SetOut(&stdout) + stdoutCmd.SetArgs([]string{"--config", path}) + require.NoError(t, stdoutCmd.Execute()) + + fileCmd := NewCommand() + fileCmd.SetArgs([]string{"--config", path, "--output", outPath}) + require.NoError(t, fileCmd.Execute()) + + fileBytes, err := os.ReadFile(outPath) + require.NoError(t, err) + assert.Equal(t, stdout.String(), string(fileBytes)) +} diff --git a/internal/environments/payload.go b/internal/environments/payload.go new file mode 100644 index 0000000..480d96e --- /dev/null +++ b/internal/environments/payload.go @@ -0,0 +1,198 @@ +// Package environments emits a per-environment configuration file an operator +// applies to GitHub's Environments REST API. cascade EMITS the file; the +// operator APPLIES it (gh api / Terraform). cascade never calls the GitHub API. +// +// The emitted artifact is a small wrapper: +// +// { +// "environments": [ { ... }, { ... } ] // one entry per manifest environment, +// // in the manifest's environments order +// } +// +// Each entry pairs the directly-appliable Environments REST body with companion +// operator guidance: +// +// { +// "name": "prod", // the cascade environment name +// "gha_environment": "production",// the GitHub Environment to configure +// "environment": { ... }, // the body to PUT to the environments API +// "operator_todo": { ... } // guidance that is NOT part of the PUT body +// } +// +// The split mirrors the branch-protection command. The "environment" object is +// the exact body for +// +// PUT /repos/{owner}/{repo}/environments/{gha_environment} +// +// for the fields cascade can fully form from the manifest (wait_timer and +// deployment_branch_policy). Required reviewers are surfaced under +// operator_todo, not inside "environment": the REST API requires a numeric +// reviewer id, and the manifest carries only slugs, so the operator resolves +// each slug to an id before adding it. Expected env-scoped secret and variable +// NAMES are also operator guidance: they are created through the separate +// environment-secrets and environment-variables APIs, and cascade emits names +// only, never values. +package environments + +import ( + "encoding/json" + "fmt" + + "github.com/stablekernel/cascade/internal/config" +) + +// Payload is the full emitted artifact: one entry per manifest environment, in +// the manifest's environments order. +type Payload struct { + Environments []Environment `json:"environments"` +} + +// Environment is the per-environment config block. "environment" is the exact +// PUT body for the fields cascade can form from the manifest; "operator_todo" +// is sibling guidance that must never be sent to GitHub verbatim. +type Environment struct { + // Name is the cascade environment name (from the manifest environments list). + Name string `json:"name"` + // GHAEnvironment is the GitHub Environment to configure. It defaults to Name + // when the manifest does not set environment_config..gha_environment. + GHAEnvironment string `json:"gha_environment"` + // Environment is the body to PUT to the environments REST API. + Environment EnvironmentBody `json:"environment"` + // OperatorTodo is companion guidance, NOT part of the PUT body. + OperatorTodo OperatorTodo `json:"operator_todo"` +} + +// EnvironmentBody is the body an operator PUTs to +// /repos/{owner}/{repo}/environments/{environment_name}. Only the fields +// cascade can fully form from the manifest appear here. Reviewers are omitted on +// purpose (the API needs numeric ids; see OperatorTodo.RequiredReviewers). +type EnvironmentBody struct { + // WaitTimer is the deploy delay in minutes (0..43200). It is always emitted + // so the body is byte-stable; 0 means no wait. + WaitTimer int `json:"wait_timer"` + // DeploymentBranchPolicy selects which branches may deploy. A nil pointer + // marshals to null, GitHub's "all branches" value. + DeploymentBranchPolicy *DeploymentBranchPolicy `json:"deployment_branch_policy"` +} + +// DeploymentBranchPolicy mirrors GitHub's deployment_branch_policy object. +// Exactly one of ProtectedBranches and CustomBranchPolicies is true; GitHub +// rejects a body where both are equal. +type DeploymentBranchPolicy struct { + ProtectedBranches bool `json:"protected_branches"` + CustomBranchPolicies bool `json:"custom_branch_policies"` +} + +// OperatorTodo is companion guidance emitted alongside the PUT body. It is NOT +// accepted by the environments PUT endpoint and must be applied through the +// steps it describes rather than sent to GitHub verbatim. +type OperatorTodo struct { + // Note is human guidance describing how to apply this entry. + Note string `json:"note"` + // RequiredReviewers lists the user or team slugs to add as reviewers. The + // REST API needs each reviewer's numeric id, which the manifest does not + // carry, so the operator resolves these slugs to ids before applying. + RequiredReviewers []string `json:"required_reviewers,omitempty"` + // BranchPatterns lists branch name patterns to create as + // deployment-branch-policies (custom policy only). + BranchPatterns []string `json:"branch_patterns,omitempty"` + // TagPatterns lists tag name patterns to create as deployment-branch-policies + // of type tag (custom policy only). + TagPatterns []string `json:"tag_patterns,omitempty"` + // Secrets lists the EXPECTED env-scoped secret NAMES to create through the + // environment-secrets API. Names only, never values. + Secrets []string `json:"secrets,omitempty"` + // Variables lists the EXPECTED env-scoped variable NAMES to create through + // the environment-variables API. Names only, never values. + Variables []string `json:"variables,omitempty"` +} + +// Build assembles the Payload from a resolved manifest. It walks +// cfg.Environments in order so the output is stable and matches the manifest's +// declared environment order. For each environment it reads the optional +// environment_config. block; an absent block yields an entry whose +// gha_environment defaults to the environment name, an empty wait timer, an +// "all branches" policy, and no reviewers, secrets, or variables. +func Build(cfg *config.TrunkConfig) Payload { + out := Payload{Environments: make([]Environment, 0, len(cfg.Environments))} + for _, name := range cfg.Environments { + ec := cfg.EnvironmentConfig[name] + + ghaEnv := ec.GHAEnvironment + if ghaEnv == "" { + ghaEnv = name + } + + out.Environments = append(out.Environments, Environment{ + Name: name, + GHAEnvironment: ghaEnv, + Environment: EnvironmentBody{ + WaitTimer: ec.WaitTimer, + DeploymentBranchPolicy: branchPolicy(ec.BranchPolicy), + }, + OperatorTodo: OperatorTodo{ + Note: operatorNote(ghaEnv), + RequiredReviewers: nonEmpty(ec.RequiredReviewers), + BranchPatterns: nonEmpty(ec.BranchPatterns), + TagPatterns: nonEmpty(ec.TagPatterns), + Secrets: nonEmpty(ec.Secrets), + Variables: nonEmpty(ec.Variables), + }, + }) + } + return out +} + +// branchPolicy maps a manifest branch_policy string onto GitHub's +// deployment_branch_policy object. An empty policy or the "all" policy returns +// nil, which marshals to null (all branches may deploy). +func branchPolicy(policy string) *DeploymentBranchPolicy { + switch policy { + case config.EnvBranchPolicyProtected: + return &DeploymentBranchPolicy{ProtectedBranches: true, CustomBranchPolicies: false} + case config.EnvBranchPolicyCustom: + return &DeploymentBranchPolicy{ProtectedBranches: false, CustomBranchPolicies: true} + default: // "" or EnvBranchPolicyAll + return nil + } +} + +// nonEmpty returns s unchanged when it has elements, or nil when it is empty, so +// an empty slice marshals away (omitempty) instead of as "[]". +func nonEmpty(s []string) []string { + if len(s) == 0 { + return nil + } + return s +} + +// operatorNote returns the human guidance string for an environment. It states +// the emit-not-apply contract and how to apply the PUT body. +func operatorNote(ghaEnv string) string { + return fmt.Sprintf( + "PUT the .environment object to "+ + "repos/{owner}/{repo}/environments/%s (for example: "+ + "jq .environment | gh api -X PUT ... --input -). cascade emits this "+ + "config; you apply it. Resolve each operator_todo.required_reviewers "+ + "slug to its numeric id and add it to the body's reviewers array, then "+ + "create the operator_todo.secrets and operator_todo.variables names "+ + "through the environment-secrets and environment-variables APIs. Those "+ + "are expected NAMES only; set their values yourself. branch_patterns and "+ + "tag_patterns are created through the deployment-branch-policies API when "+ + "branch_policy is custom.", + ghaEnv, + ) +} + +// Marshal renders the payload as indented JSON with exactly one trailing +// newline. The same manifest always produces byte-identical output: the +// environment order follows the manifest, and every emitted struct field has a +// stable JSON key order. +func Marshal(p Payload) ([]byte, error) { + out, err := json.MarshalIndent(p, "", " ") + if err != nil { + return nil, fmt.Errorf("marshaling environments payload: %w", err) + } + out = append(out, '\n') + return out, nil +} diff --git a/internal/environments/payload_test.go b/internal/environments/payload_test.go new file mode 100644 index 0000000..9f85c6a --- /dev/null +++ b/internal/environments/payload_test.go @@ -0,0 +1,170 @@ +package environments + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +// fullConfig returns a manifest config exercising every additive +// environment_config field, with environments declared out of alphabetical +// order so the manifest-order guarantee is observable. +func fullConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + Environments: []string{"prod", "dev", "test"}, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": { + GHAEnvironment: "production", + RequiredReviewers: []string{"octocat", "team/ops"}, + WaitTimer: 10, + BranchPolicy: config.EnvBranchPolicyProtected, + Secrets: []string{"MY_SECRET", "DB_PASSWORD"}, + Variables: []string{"REGION"}, + }, + "dev": { + BranchPolicy: config.EnvBranchPolicyCustom, + BranchPatterns: []string{"main", "release/*"}, + TagPatterns: []string{"v*"}, + }, + // "test" intentionally has no environment_config entry. + }, + } +} + +// TestBuild_OrdersByManifestAndDefaults asserts the payload follows the +// manifest's environments order and fills defaults for environments without an +// environment_config entry. +func TestBuild_OrdersByManifestAndDefaults(t *testing.T) { + t.Parallel() + + p := Build(fullConfig()) + + require.Len(t, p.Environments, 3) + assert.Equal(t, []string{"prod", "dev", "test"}, + []string{p.Environments[0].Name, p.Environments[1].Name, p.Environments[2].Name}, + "environments must follow manifest order") + + // prod: gha_environment override + protected policy + wait timer. + prod := p.Environments[0] + assert.Equal(t, "production", prod.GHAEnvironment) + assert.Equal(t, 10, prod.Environment.WaitTimer) + require.NotNil(t, prod.Environment.DeploymentBranchPolicy) + assert.True(t, prod.Environment.DeploymentBranchPolicy.ProtectedBranches) + assert.False(t, prod.Environment.DeploymentBranchPolicy.CustomBranchPolicies) + assert.Equal(t, []string{"octocat", "team/ops"}, prod.OperatorTodo.RequiredReviewers) + assert.Equal(t, []string{"MY_SECRET", "DB_PASSWORD"}, prod.OperatorTodo.Secrets) + assert.Equal(t, []string{"REGION"}, prod.OperatorTodo.Variables) + + // dev: custom policy with patterns, gha_environment defaults to name. + dev := p.Environments[1] + assert.Equal(t, "dev", dev.GHAEnvironment) + require.NotNil(t, dev.Environment.DeploymentBranchPolicy) + assert.False(t, dev.Environment.DeploymentBranchPolicy.ProtectedBranches) + assert.True(t, dev.Environment.DeploymentBranchPolicy.CustomBranchPolicies) + assert.Equal(t, []string{"main", "release/*"}, dev.OperatorTodo.BranchPatterns) + assert.Equal(t, []string{"v*"}, dev.OperatorTodo.TagPatterns) + + // test: no environment_config entry -> defaults. + tst := p.Environments[2] + assert.Equal(t, "test", tst.GHAEnvironment) + assert.Equal(t, 0, tst.Environment.WaitTimer) + assert.Nil(t, tst.Environment.DeploymentBranchPolicy, "absent policy marshals to null (all branches)") + assert.Nil(t, tst.OperatorTodo.RequiredReviewers) + assert.Nil(t, tst.OperatorTodo.Secrets) + assert.Nil(t, tst.OperatorTodo.Variables) +} + +// TestBuild_NoEnvironmentConfigBlock confirms a manifest with environments but +// no environment_config map still emits one default entry per environment. +func TestBuild_NoEnvironmentConfigBlock(t *testing.T) { + t.Parallel() + + cfg := &config.TrunkConfig{Environments: []string{"staging", "prod"}} + p := Build(cfg) + + require.Len(t, p.Environments, 2) + assert.Equal(t, "staging", p.Environments[0].Name) + assert.Equal(t, "staging", p.Environments[0].GHAEnvironment) + assert.Nil(t, p.Environments[0].Environment.DeploymentBranchPolicy) + assert.Equal(t, "prod", p.Environments[1].Name) +} + +// TestBuild_AllBranchPolicyIsNull confirms the "all" policy marshals to a null +// deployment_branch_policy, GitHub's all-branches value. +func TestBuild_AllBranchPolicyIsNull(t *testing.T) { + t.Parallel() + + cfg := &config.TrunkConfig{ + Environments: []string{"prod"}, + EnvironmentConfig: map[string]config.EnvironmentConfig{ + "prod": {BranchPolicy: config.EnvBranchPolicyAll}, + }, + } + p := Build(cfg) + assert.Nil(t, p.Environments[0].Environment.DeploymentBranchPolicy) +} + +// TestMarshal_Deterministic is the acceptance-criterion drift test: the same +// manifest always produces byte-identical output. +func TestMarshal_Deterministic(t *testing.T) { + t.Parallel() + + first, err := Marshal(Build(fullConfig())) + require.NoError(t, err) + for i := 0; i < 5; i++ { + again, err := Marshal(Build(fullConfig())) + require.NoError(t, err) + assert.Equal(t, string(first), string(again), "output must be byte-identical across runs") + } +} + +// TestMarshal_ValidJSONAndTrailingNewline confirms the output is valid JSON, +// round-trippable, and ends in exactly one trailing newline. +func TestMarshal_ValidJSONAndTrailingNewline(t *testing.T) { + t.Parallel() + + out, err := Marshal(Build(fullConfig())) + require.NoError(t, err) + + require.Greater(t, len(out), 1) + assert.Equal(t, byte('\n'), out[len(out)-1], "must end with a newline") + assert.NotEqual(t, byte('\n'), out[len(out)-2], "must end with exactly one newline") + + var round Payload + require.NoError(t, json.Unmarshal(out, &round), "output must be valid, round-trippable JSON") + require.Len(t, round.Environments, 3) + assert.Equal(t, "prod", round.Environments[0].Name) +} + +// TestMarshal_SecretsAndVariablesAreNamesOnly is a guardrail: the emitted JSON +// carries secret and variable NAMES and never a values map or inline value. +func TestMarshal_SecretsAndVariablesAreNamesOnly(t *testing.T) { + t.Parallel() + + out, err := Marshal(Build(fullConfig())) + require.NoError(t, err) + + var generic map[string]any + require.NoError(t, json.Unmarshal(out, &generic)) + + envs, ok := generic["environments"].([]any) + require.True(t, ok) + prod, ok := envs[0].(map[string]any) + require.True(t, ok) + todo, ok := prod["operator_todo"].(map[string]any) + require.True(t, ok) + + // secrets and variables are arrays of strings (names), not objects (values). + secrets, ok := todo["secrets"].([]any) + require.True(t, ok, "secrets must be a list of names") + for _, s := range secrets { + _, isString := s.(string) + assert.True(t, isString, "each secret entry must be a bare name string") + } + _, hasValue := todo["secret_values"] + assert.False(t, hasValue, "no value-carrying field may be emitted") +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 11b3e22..21b76c8 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -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)." } } }, diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 11b3e22..21b76c8 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -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)." } } },